From e4a98cd0ad4443965faec0da153c007af2ca03e9 Mon Sep 17 00:00:00 2001 From: klockky <108465095+klockky@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:44:58 -0700 Subject: [PATCH 001/323] Create LICENSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit инк я тебе яйца куда нибудь натяну с этими пул реквестами --- LICENSE | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From fe43d2a09114ed5afc3a47360c59ac63a2f7b22d Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 12 Mar 2026 18:54:09 +0300 Subject: [PATCH 002/323] feat: initial project setup - Komet Flutter app --- .gitignore | 11 +- analysis_options.yaml | 28 + android/.gitignore | 14 + android/app/build.gradle.kts | 44 ++ android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 45 ++ .../main/kotlin/ru/komet/app/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + android/app/src/main/res/values/styles.xml | 18 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle.kts | 24 + android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android/settings.gradle.kts | 26 + ios/.gitignore | 34 + ios/Flutter/AppFrameworkInfo.plist | 26 + ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Runner.xcodeproj/project.pbxproj | 616 +++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 101 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + ios/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 122 +++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + ios/Runner/Base.lproj/LaunchScreen.storyboard | 37 + ios/Runner/Base.lproj/Main.storyboard | 26 + ios/Runner/Info.plist | 49 ++ ios/Runner/Runner-Bridging-Header.h | 1 + ios/RunnerTests/RunnerTests.swift | 12 + lib/backend/api.dart | 0 lib/backend/modules/account.dart | 0 lib/backend/modules/calls.dart | 0 lib/backend/modules/chats.dart | 0 lib/backend/modules/messages.dart | 0 lib/core/config/config.dart | 0 lib/core/protocol/opcode_map.dart | 0 lib/core/protocol/packet.dart | 133 ++++ lib/core/transport/connection.dart | 0 lib/core/transport/dispatcher.dart | 0 lib/core/transport/receiver.dart | 0 lib/core/transport/sender.dart | 0 lib/core/utils/logger.dart | 0 lib/core/utils/serializer.dart | 0 lib/frontend/screens/auth/login_screen.dart | 0 .../screens/auth/register_screen.dart | 0 lib/frontend/screens/calls/call_screen.dart | 0 .../screens/chats/chat_list_screen.dart | 0 lib/frontend/screens/chats/chat_screen.dart | 0 .../screens/profile/profile_screen.dart | 0 lib/frontend/widgets/avatar.dart | 0 lib/frontend/widgets/call_controls.dart | 0 lib/frontend/widgets/chat_tile.dart | 0 lib/frontend/widgets/message_bubble.dart | 0 lib/main.dart | 122 +++ lib/models/call.dart | 0 lib/models/chat.dart | 0 lib/models/message.dart | 0 lib/models/session.dart | 0 lib/models/user.dart | 0 lib/state/account_state.dart | 0 lib/state/call_state.dart | 0 lib/state/chat_state.dart | 0 lib/state/message_state.dart | 0 lib/storage/cache.dart | 0 lib/storage/local_storage.dart | 0 linux/.gitignore | 1 + linux/CMakeLists.txt | 128 ++++ linux/flutter/CMakeLists.txt | 88 +++ linux/runner/CMakeLists.txt | 26 + linux/runner/main.cc | 6 + linux/runner/my_application.cc | 148 ++++ linux/runner/my_application.h | 21 + macos/.gitignore | 7 + macos/Flutter/Flutter-Debug.xcconfig | 1 + macos/Flutter/Flutter-Release.xcconfig | 1 + macos/Runner.xcodeproj/project.pbxproj | 705 ++++++++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 99 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + macos/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 68 ++ .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 102994 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 5680 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 520 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 14142 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1066 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 36406 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 2218 bytes macos/Runner/Base.lproj/MainMenu.xib | 343 +++++++++ macos/Runner/Configs/AppInfo.xcconfig | 14 + macos/Runner/Configs/Debug.xcconfig | 2 + macos/Runner/Configs/Release.xcconfig | 2 + macos/Runner/Configs/Warnings.xcconfig | 13 + macos/Runner/DebugProfile.entitlements | 12 + macos/Runner/Info.plist | 32 + macos/Runner/MainFlutterWindow.swift | 15 + macos/Runner/Release.entitlements | 8 + macos/RunnerTests/RunnerTests.swift | 12 + pubspec.lock | 229 ++++++ pubspec.yaml | 91 +++ test/widget_test.dart | 30 + windows/.gitignore | 17 + windows/CMakeLists.txt | 108 +++ windows/flutter/CMakeLists.txt | 109 +++ windows/runner/CMakeLists.txt | 40 + windows/runner/Runner.rc | 121 +++ windows/runner/flutter_window.cpp | 71 ++ windows/runner/flutter_window.h | 33 + windows/runner/main.cpp | 43 ++ windows/runner/resource.h | 16 + windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes windows/runner/runner.exe.manifest | 14 + windows/runner/utils.cpp | 65 ++ windows/runner/utils.h | 19 + windows/runner/win32_window.cpp | 288 +++++++ windows/runner/win32_window.h | 102 +++ 148 files changed, 4829 insertions(+), 1 deletion(-) create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/ru/komet/app/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle.kts create mode 100644 ios/.gitignore create mode 100644 ios/Flutter/AppFrameworkInfo.plist create mode 100644 ios/Flutter/Debug.xcconfig create mode 100644 ios/Flutter/Release.xcconfig create mode 100644 ios/Runner.xcodeproj/project.pbxproj create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner/AppDelegate.swift create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 ios/Runner/Base.lproj/Main.storyboard create mode 100644 ios/Runner/Info.plist create mode 100644 ios/Runner/Runner-Bridging-Header.h create mode 100644 ios/RunnerTests/RunnerTests.swift create mode 100644 lib/backend/api.dart create mode 100644 lib/backend/modules/account.dart create mode 100644 lib/backend/modules/calls.dart create mode 100644 lib/backend/modules/chats.dart create mode 100644 lib/backend/modules/messages.dart create mode 100644 lib/core/config/config.dart create mode 100644 lib/core/protocol/opcode_map.dart create mode 100644 lib/core/protocol/packet.dart create mode 100644 lib/core/transport/connection.dart create mode 100644 lib/core/transport/dispatcher.dart create mode 100644 lib/core/transport/receiver.dart create mode 100644 lib/core/transport/sender.dart create mode 100644 lib/core/utils/logger.dart create mode 100644 lib/core/utils/serializer.dart create mode 100644 lib/frontend/screens/auth/login_screen.dart create mode 100644 lib/frontend/screens/auth/register_screen.dart create mode 100644 lib/frontend/screens/calls/call_screen.dart create mode 100644 lib/frontend/screens/chats/chat_list_screen.dart create mode 100644 lib/frontend/screens/chats/chat_screen.dart create mode 100644 lib/frontend/screens/profile/profile_screen.dart create mode 100644 lib/frontend/widgets/avatar.dart create mode 100644 lib/frontend/widgets/call_controls.dart create mode 100644 lib/frontend/widgets/chat_tile.dart create mode 100644 lib/frontend/widgets/message_bubble.dart create mode 100644 lib/main.dart create mode 100644 lib/models/call.dart create mode 100644 lib/models/chat.dart create mode 100644 lib/models/message.dart create mode 100644 lib/models/session.dart create mode 100644 lib/models/user.dart create mode 100644 lib/state/account_state.dart create mode 100644 lib/state/call_state.dart create mode 100644 lib/state/chat_state.dart create mode 100644 lib/state/message_state.dart create mode 100644 lib/storage/cache.dart create mode 100644 lib/storage/local_storage.dart create mode 100644 linux/.gitignore create mode 100644 linux/CMakeLists.txt create mode 100644 linux/flutter/CMakeLists.txt create mode 100644 linux/runner/CMakeLists.txt create mode 100644 linux/runner/main.cc create mode 100644 linux/runner/my_application.cc create mode 100644 linux/runner/my_application.h create mode 100644 macos/.gitignore create mode 100644 macos/Flutter/Flutter-Debug.xcconfig create mode 100644 macos/Flutter/Flutter-Release.xcconfig create mode 100644 macos/Runner.xcodeproj/project.pbxproj create mode 100644 macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 macos/Runner/AppDelegate.swift create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 macos/Runner/Base.lproj/MainMenu.xib create mode 100644 macos/Runner/Configs/AppInfo.xcconfig create mode 100644 macos/Runner/Configs/Debug.xcconfig create mode 100644 macos/Runner/Configs/Release.xcconfig create mode 100644 macos/Runner/Configs/Warnings.xcconfig create mode 100644 macos/Runner/DebugProfile.entitlements create mode 100644 macos/Runner/Info.plist create mode 100644 macos/Runner/MainFlutterWindow.swift create mode 100644 macos/Runner/Release.entitlements create mode 100644 macos/RunnerTests/RunnerTests.swift create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/widget_test.dart create mode 100644 windows/.gitignore create mode 100644 windows/CMakeLists.txt create mode 100644 windows/flutter/CMakeLists.txt create mode 100644 windows/runner/CMakeLists.txt create mode 100644 windows/runner/Runner.rc create mode 100644 windows/runner/flutter_window.cpp create mode 100644 windows/runner/flutter_window.h create mode 100644 windows/runner/main.cpp create mode 100644 windows/runner/resource.h create mode 100644 windows/runner/resources/app_icon.ico create mode 100644 windows/runner/runner.exe.manifest create mode 100644 windows/runner/utils.cpp create mode 100644 windows/runner/utils.h create mode 100644 windows/runner/win32_window.cpp create mode 100644 windows/runner/win32_window.h diff --git a/.gitignore b/.gitignore index 39b8814..14601b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,21 @@ # Miscellaneous *.class -*.lock *.log *.pyc *.swp .buildlog/ .history +# IDE - IntelliJ / Android Studio +.idea/ +*.iml + +# Flutter metadata +.metadata + +# OS +.DS_Store + # Flutter repo-specific diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..1e361bc --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "ru.komet.app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "ru.komet.app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c8fd234 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt new file mode 100644 index 0000000..588733f --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -0,0 +1,5 @@ +package ru.komet.app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 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..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..765a142 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..7014b43 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Komet + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + komet + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/backend/api.dart b/lib/backend/api.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/config/config.dart b/lib/core/config/config.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart new file mode 100644 index 0000000..617d05d --- /dev/null +++ b/lib/core/protocol/packet.dart @@ -0,0 +1,133 @@ +import 'dart:typed_data'; +import 'package:dart_lz4/dart_lz4.dart'; +import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; + +final int apiVersion = 10; +int seq = 1; + +/* + Базовый класс пакета + + api - версия API (по умолчанию 10 для сокета) + cmd - от кого был прислан пакет ( + 0 - клиент (если отправляется серверу) + 1 - сервер + 3 - отправляется вместе с ошибкой) + seq - счёт пакетов (начинается с 1) + opcode - показывает, для чего отправляется пакет (подробнее в /lib/core/protocol/opcode_map.dart) + payload - содержимое пакета (отправляется с сервера ввиде msgpack) +*/ +class Packet { + int api; + int cmd; + int seq; + int opcode; + Map payload; + + Packet({ + this.api = 10, + this.cmd = 0, + this.seq = 1, + this.opcode = 0, + this.payload = const {}, + }); + + /// Выводит пакет в консоль + void printPacket() { + print( + "Api: $api\nCmd: $cmd\nSeq: $seq\nOPCode: $opcode\nPayload: $payload", + ); + } +} + +/// Функция запаковки пакета +/// Принимает только opcode и payload, так как api и cmd +/// константы (10 и 0 соответственно), а seq считается менеджером сокета +/// Возращает запакованный пакет +Uint8List packPacket(int opcode, Map payload) { + // Объяснение каждой переменной смотри в классе Packet + + final apiVerB = Uint8List(1)..[0] = apiVersion; + final cmdB = Uint8List(1)..[0] = 0; + final seqB = Uint8List(2)..buffer.asByteData().setUint16(0, seq, Endian.big); + final opcodeB = Uint8List(2) + ..buffer.asByteData().setUint16(0, opcode, Endian.big); + + // Перед получением длины пакуем в msgpack + final payloadBytes = msgpack.serialize(payload); + final payloadLen = payloadBytes.length & 0xFFFFFF; + + final payloadLenB = Uint8List(4) + ..buffer.asByteData().setUint32(0, payloadLen, Endian.big); + + return Uint8List.fromList( + apiVerB + cmdB + seqB + opcodeB + payloadLenB + payloadBytes, + ); +} + +/// Функция распаковки пакета +/// Принимает байты пакета, которые отправляет сервер +/// Возращает Packet +Packet unpackPacket(Uint8List packet) { + // Для удобства расшифровки пакета переводим в ByteData + ByteData packetData = ByteData.view(packet.buffer); + + // Объяснение каждой переменной смотри в классе Packet + + // API версия и cmd представляют из себя 8 битные числа + var apiVer = packetData.getUint8(0) & 0xFF; + var cmd = packetData.getUint8(1) & 0xFF; + + // Sequence и OPCode представляют из себя 16 битные числа + var seq = packetData.getUint16(2) & 0xFFFF; + var opcode = packetData.getUint16(4) & 0xFFFF; + + // После базовых переменных идет длина пакета, является 32 битным числом + var packedLen = packetData.getUint32(6); + + // Compression flag показывает, сжат ли payload + var compFlag = packedLen >> 24; + + // Длина payload'а + var payloadLength = packedLen & 0xFFFFFF; + + // Байты payload'а, могут быть сжаты LZ4 + var payloadBytes = packet.buffer.asUint8List(10, payloadLength); + + var payload = {}; + + // Если payload пустой, ничего не делаем (так может быть при получении пинга) + if (payloadBytes.buffer.lengthInBytes > 0) { + // Если пакет сжат, используем LZ4 + if (compFlag != 0) { + try { + final decompressedBytes = lz4Decompress( + payloadBytes, + decompressedSize: 99999, + ); + + payloadBytes = decompressedBytes; + } catch (e) { + print("Ошибка при декомпрессировании: $e"); + } + } + + // Пробуем десериализовать msgpack + try { + payload = msgpack.deserialize(payloadBytes); + } catch (e) { + print("Ошибка при десериализации msgpack: $e"); + } + } + + // Собираем целый пакет и возращаем его + final fullPacket = Packet( + api: apiVer, + cmd: cmd, + seq: seq, + opcode: opcode, + payload: payload, + ); + + return fullPacket; +} diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/utils/serializer.dart b/lib/core/utils/serializer.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/frontend/screens/auth/register_screen.dart b/lib/frontend/screens/auth/register_screen.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/frontend/screens/profile/profile_screen.dart b/lib/frontend/screens/profile/profile_screen.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/frontend/widgets/avatar.dart b/lib/frontend/widgets/avatar.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/frontend/widgets/call_controls.dart b/lib/frontend/widgets/call_controls.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/frontend/widgets/chat_tile.dart b/lib/frontend/widgets/chat_tile.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..244a702 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + // This is the theme of your application. + // + // TRY THIS: Try running your application with "flutter run". You'll see + // the application has a purple toolbar. Then, without quitting the app, + // try changing the seedColor in the colorScheme below to Colors.green + // and then invoke "hot reload" (save your changes or press the "hot + // reload" button in a Flutter-supported IDE, or press "r" if you used + // the command line to start the app). + // + // Notice that the counter didn't reset back to zero; the application + // state is not lost during the reload. To reset the state, use hot + // restart instead. + // + // This works for code too, not just values: Most code changes can be + // tested with just a hot reload. + colorScheme: .fromSeed(seedColor: Colors.deepPurple), + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + // This widget is the home page of your application. It is stateful, meaning + // that it has a State object (defined below) that contains fields that affect + // how it looks. + + // This class is the configuration for the state. It holds the values (in this + // case the title) provided by the parent (in this case the App widget) and + // used by the build method of the State. Fields in a Widget subclass are + // always marked "final". + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + // This call to setState tells the Flutter framework that something has + // changed in this State, which causes it to rerun the build method below + // so that the display can reflect the updated values. If we changed + // _counter without calling setState(), then the build method would not be + // called again, and so nothing would appear to happen. + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + // This method is rerun every time setState is called, for instance as done + // by the _incrementCounter method above. + // + // The Flutter framework has been optimized to make rerunning build methods + // fast, so that you can just rebuild anything that needs updating rather + // than having to individually change instances of widgets. + return Scaffold( + appBar: AppBar( + // TRY THIS: Try changing the color here to a specific color (to + // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar + // change color while the other colors stay the same. + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + // Here we take the value from the MyHomePage object that was created by + // the App.build method, and use it to set our appbar title. + title: Text(widget.title), + ), + body: Center( + // Center is a layout widget. It takes a single child and positions it + // in the middle of the parent. + child: Column( + // Column is also a layout widget. It takes a list of children and + // arranges them vertically. By default, it sizes itself to fit its + // children horizontally, and tries to be as tall as its parent. + // + // Column has various properties to control how it sizes itself and + // how it positions its children. Here we use mainAxisAlignment to + // center the children vertically; the main axis here is the vertical + // axis because Columns are vertical (the cross axis would be + // horizontal). + // + // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" + // action in the IDE, or press "p" in the console), to see the + // wireframe for each widget. + mainAxisAlignment: .center, + children: [ + const Text('You have pushed the button this many times:'), + Text( + '$_counter', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/models/call.dart b/lib/models/call.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/models/chat.dart b/lib/models/chat.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/models/message.dart b/lib/models/message.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/models/session.dart b/lib/models/session.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/state/account_state.dart b/lib/state/account_state.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/state/call_state.dart b/lib/state/call_state.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/state/chat_state.dart b/lib/state/chat_state.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/state/message_state.dart b/lib/state/message_state.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/storage/cache.dart b/lib/storage/cache.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/storage/local_storage.dart b/lib/storage/local_storage.dart new file mode 100644 index 0000000..e69de29 diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..74a50c9 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "komet") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "ru.komet.app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..380209e --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "komet"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "komet"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2110d8f --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* komet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "komet.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* komet.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* komet.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/komet.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/komet"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/komet.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/komet"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/komet.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/komet"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ff145df --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..82b6f9d9a33e198f5747104729e1fcef999772a5 GIT binary patch literal 102994 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ⅅ8MglG&ER{i5lN315cO?EdHNLrg? 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 z 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 Pe7HEdAEg|=c=LY&YVNkY literal 0 HcmV?d00001 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000000000000000000000000000000000..13b35eba55c6dabc3aac36f33d859266c18fa0d0 GIT binary patch literal 5680 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 literal 0 HcmV?d00001 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3f5fa40fb3d1e0710331a48de5d256da3f275d GIT binary patch literal 520 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 literal 0 HcmV?d00001 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1632cfddf3d9dade342351e627a0a75609fb46 GIT binary patch literal 2218 zcmV;b2vzrqP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuE6iGxuRCodHTWf3-RTMruyW6Fu zQYeUM04eX6D5c0FCjKKPrco1(K`<0SL=crI{PC3-^hZU0kQie$gh-5!7z6SH6Q0J% zqot*`H1q{R5fHFYS}dje@;kG=v$L0(yY0?wY2%*c?A&{2?!D*x?m71{of2gv!$5|C z3>qG_BW}7K_yUcT3A5C6QD<+{aq?x;MAUyAiJn#Jv8_zZtQ{P zTRzbL3U9!qVuZzS$xKU10KiW~Bgdcv1-!uAhQxf3a7q+dU6lj?yoO4Lq4TUN4}h{N z*fIM=SS8|C2$(T>w$`t@3Tka!(r!7W`x z-isCVgQD^mG-MJ;XtJuK3V{Vy72GQ83KRWsHU?e*wrhKk=ApIYeDqLi;JI1e zuvv}5^Dc=k7F7?nm3nIw$NVmU-+R>> zyqOR$-2SDpJ}Pt;^RkJytDVXNTsu|mI1`~G7yw`EJR?VkGfNdqK9^^8P`JdtTV&tX4CNcV4 z&N06nZa??Fw1AgQOUSE2AmPE@WO(Fvo`%m`cDgiv(fAeRA%3AGXUbsGw{7Q`cY;1BI#ac3iN$$Hw z0LT0;xc%=q)me?Y*$xI@GRAw?+}>=9D+KTk??-HJ4=A>`V&vKFS75@MKdSF1JTq{S zc1!^8?YA|t+uKigaq!sT;Z!&0F2=k7F0PIU;F$leJLaw2UI6FL^w}OG&!;+b%ya1c z1n+6-inU<0VM-Y_s5iTElq)ThyF?StVcebpGI znw#+zLx2@ah{$_2jn+@}(zJZ{+}_N9BM;z)0yr|gF-4=Iyu@hI*Lk=-A8f#bAzc9f z`Kd6K--x@t04swJVC3JK1cHY-Hq+=|PN-VO;?^_C#;coU6TDP7Bt`;{JTG;!+jj(` zw5cLQ-(Cz-Tlb`A^w7|R56Ce;Wmr0)$KWOUZ6ai0PhzPeHwdl0H(etP zUV`va_i0s-4#DkNM8lUlqI7>YQLf)(lz9Q3Uw`)nc(z3{m5ZE77Ul$V%m)E}3&8L0 z-XaU|eB~Is08eORPk;=<>!1w)Kf}FOVS2l&9~A+@R#koFJ$Czd%Y(ENTV&A~U(IPI z;UY+gf+&6ioZ=roly<0Yst8ck>(M=S?B-ys3mLdM&)ex!hbt+ol|T6CTS+Sc0jv(& z7ijdvFwBq;0a{%3GGwkDKTeG`b+lyj0jjS1OMkYnepCdoosNY`*zmBIo*981BU%%U z@~$z0V`OVtIbEx5pa|Tct|Lg#ZQf5OYMUMRD>Wdxm5SAqV2}3!ceE-M2 z@O~lQ0OiKQp}o9I;?uxCgYVV?FH|?Riri*U$Zi_`V2eiA>l zdSm6;SEm6#T+SpcE8Ro_f2AwxzI z44hfe^WE3!h@W3RDyA_H440cpmYkv*)6m1XazTqw%=E5Xv7^@^^T7Q2wxr+Z2kVYr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..e8f873a --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = komet + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 ru.komet. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..ac0db0a --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,229 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_lz4: + dependency: "direct main" + description: + name: dart_lz4 + sha256: "344ffdf3938348b5057c852f3d2aa8736b4fce9d70f636e22acdbd8b934dd694" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + msgpack_dart: + dependency: "direct main" + description: + name: msgpack_dart + sha256: c2d235ed01f364719b5296aecf43ac330f0d7bc865fa134d0d7910a40454dffb + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" +sdks: + dart: ">=3.10.4 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..be405ed --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,91 @@ +name: komet +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.4 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + dart_lz4: ^1.0.0 + msgpack_dart: ^1.0.1 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..b921755 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:komet/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..7b65d96 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(komet LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "komet") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..9427177 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "ru.komet" "\0" + VALUE "FileDescription", "komet" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "komet" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 ru.komet. All rights reserved." "\0" + VALUE "OriginalFilename", "komet.exe" "\0" + VALUE "ProductName", "komet" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..e9e529a --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"komet", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 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 literal 0 HcmV?d00001 diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ From 16032e5e7ddcecf443c7e91ce74dffa2a2ad2311 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 12 Mar 2026 19:18:43 +0300 Subject: [PATCH 003/323] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20logging=20framework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/protocol/packet.dart | 8 +++++--- lib/core/utils/logger.dart | 14 ++++++++++++++ pubspec.lock | 8 ++++++++ pubspec.yaml | 1 + 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 617d05d..34fc2ba 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -2,6 +2,8 @@ import 'dart:typed_data'; import 'package:dart_lz4/dart_lz4.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; +import '../utils/logger.dart'; + final int apiVersion = 10; int seq = 1; @@ -34,7 +36,7 @@ class Packet { /// Выводит пакет в консоль void printPacket() { - print( + logger.i( "Api: $api\nCmd: $cmd\nSeq: $seq\nOPCode: $opcode\nPayload: $payload", ); } @@ -108,7 +110,7 @@ Packet unpackPacket(Uint8List packet) { payloadBytes = decompressedBytes; } catch (e) { - print("Ошибка при декомпрессировании: $e"); + logger.e("Ошибка при декомпрессировании: $e", error: e); } } @@ -116,7 +118,7 @@ Packet unpackPacket(Uint8List packet) { try { payload = msgpack.deserialize(payloadBytes); } catch (e) { - print("Ошибка при десериализации msgpack: $e"); + logger.e("Ошибка при десериализации msgpack: $e", error: e); } } diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index e69de29..5fbcd7b 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -0,0 +1,14 @@ +import 'package:logger/logger.dart'; + +final logger = Logger( + // устанавливает минимальный уровень логов. + // доступны уровни: all, trace, debug, info, warning, error, fatal, off + level: Level.info, + printer: PrettyPrinter( + methodCount: 0, + errorMethodCount: 5, + lineLength: 80, + colors: true, + printEmojis: true, + ), +); diff --git a/pubspec.lock b/pubspec.lock index ac0db0a..3e29bd2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -115,6 +115,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logger: + dependency: "direct main" + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index be405ed..a704a79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: cupertino_icons: ^1.0.8 dart_lz4: ^1.0.0 msgpack_dart: ^1.0.1 + logger: ^2.6.2 dev_dependencies: flutter_test: From b2d60ccdd42d63fb676cd1bb74fc214a4285f5f6 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 14 Mar 2026 22:29:53 +0300 Subject: [PATCH 004/323] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BA=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D1=83,?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D1=8B=20=D0=B2=20opcode=5Fmap.dart=20=D0=B8?= =?UTF-8?q?=20=D0=B8=D1=85=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5,=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D1=83=20se?= =?UTF-8?q?ssionInit=20=D0=B8=20=D0=BF=D0=B8=D0=BD=D0=B3=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=B6=D0=B4=D1=8B=D0=B5=2030=20=D1=81=D0=B5=D0=BA=D1=83?= =?UTF-8?q?=D0=BD=D0=B4=20=D1=82=D0=B0=D0=BA=D0=B6=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BB=20packet.dart=20(?= =?UTF-8?q?=D1=82.=D0=BA.=20=D1=81=D1=82=D0=B0=D0=BD=D0=B4=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BD=D0=B0=D1=8F=20=D0=B1=D0=B8=D0=B1=D0=BB=D0=B0=20dar?= =?UTF-8?q?t=5Flz4=20=D0=BD=D0=B5=20=D1=83=D0=BC=D0=B5=D0=B5=D1=82=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20=D1=81=20?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=D0=BC=D0=B8)=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20countries.dart=20(=D0=B2=20?= =?UTF-8?q?=D0=B1=D1=83=D0=B4=D1=83=D1=89=D0=B5=D0=BC=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=B3=D0=BE=D0=B4=D0=B8=D1=82=D1=81=D1=8F=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=BF=D0=B5=D0=BD=20=D0=B2=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=B2=20MAX)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 198 +++++++++++ lib/core/config/config.dart | 7 + lib/core/config/countries.dart | 456 +++++++++++++++++++++++++ lib/core/protocol/opcode_map.dart | 356 +++++++++++++++++++ lib/core/protocol/packet.dart | 200 ++++++----- lib/core/transport/connection.dart | 86 +++++ lib/core/transport/dispatcher.dart | 115 +++++++ lib/core/transport/receiver.dart | 57 ++++ lib/core/transport/sender.dart | 25 ++ lib/core/utils/logger.dart | 2 +- lib/main.dart | 7 +- macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Release.entitlements | 2 + 13 files changed, 1430 insertions(+), 83 deletions(-) create mode 100644 lib/core/config/countries.dart diff --git a/lib/backend/api.dart b/lib/backend/api.dart index e69de29..92f4e2c 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -0,0 +1,198 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../core/config/config.dart'; +import '../core/protocol/opcode_map.dart'; +import '../core/protocol/packet.dart'; +import '../core/transport/connection.dart'; +import '../core/transport/dispatcher.dart'; +import '../core/transport/receiver.dart'; +import '../core/transport/sender.dart'; +import '../core/utils/logger.dart'; + +enum SessionState { disconnected, connecting, connected, online } + +/// Клиент API. +/// +/// Подключение, хэндшейк, пинг, реконнект. +class Api { + final Connection _connection = Connection(); + final PacketReceiver _receiver = PacketReceiver(); + final PacketSender _sender = PacketSender(); + final PacketDispatcher _dispatcher = PacketDispatcher(); + + SessionState _sessionState = SessionState.disconnected; + final _stateController = StreamController.broadcast(); + + Stream get stateStream => _stateController.stream; + SessionState get state => _sessionState; + + StreamSubscription? _dataSubscription; + StreamSubscription? _socketStateSubscription; + Timer? _pingTimer; + Timer? _reconnectTimer; + int _reconnectAttempts = 0; + bool _autoReconnect = false; + + // Публичное API + + /// Подключается к серверу, шлёт хэндшейк, запускает пинг. + Future connect() async { + if (_sessionState != SessionState.disconnected) return; + + _autoReconnect = true; + _setSessionState(SessionState.connecting); + + _dataSubscription = _connection.dataStream.listen(_onDataReceived); + _socketStateSubscription = _connection.stateStream.listen((socketState) { + if (socketState == SocketState.disconnected && + _sessionState != SessionState.disconnected) { + _onDisconnected(); + } + }); + + try { + await _connection.connect(ServerConfig.host, ServerConfig.port); + } catch (e) { + logger.e('Не удалось подключиться: $e'); + _cleanup(); + _setSessionState(SessionState.disconnected); + _scheduleReconnect(); + return; + } + + _setSessionState(SessionState.connected); + _reconnectAttempts = 0; + + try { + final response = await sendHandshake(); + if (response.isOk) { + _setSessionState(SessionState.online); + _startPinging(); + logger.i('Сессия онлайн, хэндшейк ок'); + } else { + logger.e('Хэндшейк отклонён: ${response.payload}'); + } + } catch (e) { + logger.e('Ошибка хэндшейка: $e'); + } + } + + /// Отключается без автореконнекта. + Future disconnect() async { + _autoReconnect = false; + _reconnectTimer?.cancel(); + _cleanup(); + await _connection.disconnect(); + _setSessionState(SessionState.disconnected); + } + + /// Отправляет хэндшейк (opcode 6). + Future sendHandshake() { + // TODO: заменить захардкоженные данные на реальные + final payload = { + 'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000', + 'clientSessionId': 42, + 'deviceId': 'a1b2c3d4e5f6a7b8', + 'userAgent': { + 'deviceType': "ANDROID", + 'locale': 'en', + 'deviceLocale': 'en_US', + 'osVersion': 'Ondroid 14', + 'deviceName': 'KometPhone', + 'appVersion': '26.8.1', + 'screen': '1920x1080', + 'timezone': 'Europe/Moscow', + 'pushDeviceType': 'GCM', + 'arch': 'arm64', + 'buildNumber': 6606, + }, + }; + + return sendRequest(Opcode.sessionInit, payload); + } + + /// Отправляет запрос и ждёт ответ от сервера. + Future sendRequest( + int opcode, + Map payload, + ) { + final seq = _sender.send(_connection, opcode, payload); + return _dispatcher.registerPending(seq).timeout( + ServerConfig.requestTimeout, + onTimeout: () => + throw TimeoutException('${Opcode.name(opcode)} таймаут'), + ); + } + + /// Вешает обработчик на пуши с указанным опкодом. + void registerPushHandler(int opcode, void Function(Packet) handler) { + _dispatcher.registerHandler(opcode, handler); + } + + /// Стрим всех входящих пушей от сервера. + Stream get pushStream => _dispatcher.pushStream; + + void dispose() { + _autoReconnect = false; + _reconnectTimer?.cancel(); + _cleanup(); + _dispatcher.dispose(); + _connection.dispose(); + _stateController.close(); + } + + // Внутрянка + + void _setSessionState(SessionState state) { + if (_sessionState == state) return; + _sessionState = state; + _stateController.add(state); + logger.i('Сессия: ${state.name}'); + } + + void _onDataReceived(Uint8List data) { + for (final packet in _receiver.feed(data)) { + _dispatcher.dispatch(packet); + } + } + + void _onDisconnected() { + _cleanup(); + _setSessionState(SessionState.disconnected); + if (_autoReconnect) _scheduleReconnect(); + } + + void _cleanup() { + _pingTimer?.cancel(); + _dataSubscription?.cancel(); + _socketStateSubscription?.cancel(); + _dataSubscription = null; + _socketStateSubscription = null; + _receiver.reset(); + _dispatcher.clearPending(); + } + + void _startPinging() { + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) { + if (_connection.isConnected) { + _sender.send(_connection, Opcode.ping, {}); + } + }); + } + + void _scheduleReconnect() { + if (_reconnectAttempts >= ServerConfig.maxReconnectAttempts) { + logger.e('Лимит попыток реконнекта'); + return; + } + + final delaySec = (2 * (1 << _reconnectAttempts)).clamp(2, 30); + _reconnectAttempts++; + logger.i('Реконнект через ${delaySec}с (попытка $_reconnectAttempts)'); + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(Duration(seconds: delaySec), connect); + } +} diff --git a/lib/core/config/config.dart b/lib/core/config/config.dart index e69de29..4ef18b3 100644 --- a/lib/core/config/config.dart +++ b/lib/core/config/config.dart @@ -0,0 +1,7 @@ +abstract class ServerConfig { + static const String host = 'api.oneme.ru'; + static const int port = 443; + static const Duration pingInterval = Duration(seconds: 30); + static const Duration requestTimeout = Duration(seconds: 30); + static const int maxReconnectAttempts = 50; +} diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart new file mode 100644 index 0000000..860f069 --- /dev/null +++ b/lib/core/config/countries.dart @@ -0,0 +1,456 @@ +import 'dart:convert'; + +class CountryName { + final String code; // ISO 3166-1 alpha-2, например "RU" + final String en; + final String ru; + + const CountryName({ + required this.code, + required this.en, + required this.ru, + }); +} + +/// Полный список стран (195 государств) с названием на русском и английском. +/// +/// Источник данных: +/// https://github.com/stefangabos/world_countries +final List allCountries = _buildCountries(); + +/// Быстрый доступ по ISO-коду. +final Map countriesByCode = { + for (final country in allCountries) country.code: country, +}; + +/// Пример использования: +/// ```dart +/// final country = exampleCountryLookup('RU'); +/// print(country?['ru']); // Россия +/// print(country?['en']); // Russian Federation +/// ``` +Map? exampleCountryLookup(String code) { + final country = countriesByCode[code.toUpperCase()]; + if (country == null) return null; + + return { + 'code': country.code, + 'ru': country.ru, + 'en': country.en, + }; +} + +List _buildCountries() { + final enList = (jsonDecode(_countriesEnJson) as List) + .cast>(); + final ruList = (jsonDecode(_countriesRuJson) as List) + .cast>(); + + final ruByCode = { + for (final item in ruList) + (item['alpha2'] as String).toUpperCase(): item['name'] as String, + }; + + final countries = enList.map((item) { + final code = (item['alpha2'] as String).toUpperCase(); + final en = item['name'] as String; + final ru = ruByCode[code] ?? en; + + return CountryName(code: code, en: en, ru: ru); + }).toList(); + + countries.sort((a, b) => a.en.compareTo(b.en)); + return countries; +} + +const String _countriesEnJson = '''[ +{"id":4,"alpha2":"af","alpha3":"afg","name":"Afghanistan"}, +{"id":8,"alpha2":"al","alpha3":"alb","name":"Albania"}, +{"id":12,"alpha2":"dz","alpha3":"dza","name":"Algeria"}, +{"id":20,"alpha2":"ad","alpha3":"and","name":"Andorra"}, +{"id":24,"alpha2":"ao","alpha3":"ago","name":"Angola"}, +{"id":28,"alpha2":"ag","alpha3":"atg","name":"Antigua and Barbuda"}, +{"id":32,"alpha2":"ar","alpha3":"arg","name":"Argentina"}, +{"id":51,"alpha2":"am","alpha3":"arm","name":"Armenia"}, +{"id":36,"alpha2":"au","alpha3":"aus","name":"Australia"}, +{"id":40,"alpha2":"at","alpha3":"aut","name":"Austria"}, +{"id":31,"alpha2":"az","alpha3":"aze","name":"Azerbaijan"}, +{"id":44,"alpha2":"bs","alpha3":"bhs","name":"Bahamas"}, +{"id":48,"alpha2":"bh","alpha3":"bhr","name":"Bahrain"}, +{"id":50,"alpha2":"bd","alpha3":"bgd","name":"Bangladesh"}, +{"id":52,"alpha2":"bb","alpha3":"brb","name":"Barbados"}, +{"id":112,"alpha2":"by","alpha3":"blr","name":"Belarus"}, +{"id":56,"alpha2":"be","alpha3":"bel","name":"Belgium"}, +{"id":84,"alpha2":"bz","alpha3":"blz","name":"Belize"}, +{"id":204,"alpha2":"bj","alpha3":"ben","name":"Benin"}, +{"id":64,"alpha2":"bt","alpha3":"btn","name":"Bhutan"}, +{"id":68,"alpha2":"bo","alpha3":"bol","name":"Bolivia, Plurinational State of"}, +{"id":70,"alpha2":"ba","alpha3":"bih","name":"Bosnia and Herzegovina"}, +{"id":72,"alpha2":"bw","alpha3":"bwa","name":"Botswana"}, +{"id":76,"alpha2":"br","alpha3":"bra","name":"Brazil"}, +{"id":96,"alpha2":"bn","alpha3":"brn","name":"Brunei Darussalam"}, +{"id":100,"alpha2":"bg","alpha3":"bgr","name":"Bulgaria"}, +{"id":854,"alpha2":"bf","alpha3":"bfa","name":"Burkina Faso"}, +{"id":108,"alpha2":"bi","alpha3":"bdi","name":"Burundi"}, +{"id":132,"alpha2":"cv","alpha3":"cpv","name":"Cabo Verde"}, +{"id":116,"alpha2":"kh","alpha3":"khm","name":"Cambodia"}, +{"id":120,"alpha2":"cm","alpha3":"cmr","name":"Cameroon"}, +{"id":124,"alpha2":"ca","alpha3":"can","name":"Canada"}, +{"id":140,"alpha2":"cf","alpha3":"caf","name":"Central African Republic"}, +{"id":148,"alpha2":"td","alpha3":"tcd","name":"Chad"}, +{"id":152,"alpha2":"cl","alpha3":"chl","name":"Chile"}, +{"id":156,"alpha2":"cn","alpha3":"chn","name":"China"}, +{"id":170,"alpha2":"co","alpha3":"col","name":"Colombia"}, +{"id":174,"alpha2":"km","alpha3":"com","name":"Comoros"}, +{"id":178,"alpha2":"cg","alpha3":"cog","name":"Congo"}, +{"id":180,"alpha2":"cd","alpha3":"cod","name":"Congo, Democratic Republic of the"}, +{"id":188,"alpha2":"cr","alpha3":"cri","name":"Costa Rica"}, +{"id":384,"alpha2":"ci","alpha3":"civ","name":"Côte d'Ivoire"}, +{"id":191,"alpha2":"hr","alpha3":"hrv","name":"Croatia"}, +{"id":192,"alpha2":"cu","alpha3":"cub","name":"Cuba"}, +{"id":196,"alpha2":"cy","alpha3":"cyp","name":"Cyprus"}, +{"id":203,"alpha2":"cz","alpha3":"cze","name":"Czechia"}, +{"id":208,"alpha2":"dk","alpha3":"dnk","name":"Denmark"}, +{"id":262,"alpha2":"dj","alpha3":"dji","name":"Djibouti"}, +{"id":212,"alpha2":"dm","alpha3":"dma","name":"Dominica"}, +{"id":214,"alpha2":"do","alpha3":"dom","name":"Dominican Republic"}, +{"id":218,"alpha2":"ec","alpha3":"ecu","name":"Ecuador"}, +{"id":818,"alpha2":"eg","alpha3":"egy","name":"Egypt"}, +{"id":222,"alpha2":"sv","alpha3":"slv","name":"El Salvador"}, +{"id":226,"alpha2":"gq","alpha3":"gnq","name":"Equatorial Guinea"}, +{"id":232,"alpha2":"er","alpha3":"eri","name":"Eritrea"}, +{"id":233,"alpha2":"ee","alpha3":"est","name":"Estonia"}, +{"id":748,"alpha2":"sz","alpha3":"swz","name":"Eswatini"}, +{"id":231,"alpha2":"et","alpha3":"eth","name":"Ethiopia"}, +{"id":242,"alpha2":"fj","alpha3":"fji","name":"Fiji"}, +{"id":246,"alpha2":"fi","alpha3":"fin","name":"Finland"}, +{"id":250,"alpha2":"fr","alpha3":"fra","name":"France"}, +{"id":266,"alpha2":"ga","alpha3":"gab","name":"Gabon"}, +{"id":270,"alpha2":"gm","alpha3":"gmb","name":"Gambia"}, +{"id":268,"alpha2":"ge","alpha3":"geo","name":"Georgia"}, +{"id":276,"alpha2":"de","alpha3":"deu","name":"Germany"}, +{"id":288,"alpha2":"gh","alpha3":"gha","name":"Ghana"}, +{"id":300,"alpha2":"gr","alpha3":"grc","name":"Greece"}, +{"id":308,"alpha2":"gd","alpha3":"grd","name":"Grenada"}, +{"id":320,"alpha2":"gt","alpha3":"gtm","name":"Guatemala"}, +{"id":324,"alpha2":"gn","alpha3":"gin","name":"Guinea"}, +{"id":624,"alpha2":"gw","alpha3":"gnb","name":"Guinea-Bissau"}, +{"id":328,"alpha2":"gy","alpha3":"guy","name":"Guyana"}, +{"id":332,"alpha2":"ht","alpha3":"hti","name":"Haiti"}, +{"id":340,"alpha2":"hn","alpha3":"hnd","name":"Honduras"}, +{"id":348,"alpha2":"hu","alpha3":"hun","name":"Hungary"}, +{"id":352,"alpha2":"is","alpha3":"isl","name":"Iceland"}, +{"id":356,"alpha2":"in","alpha3":"ind","name":"India"}, +{"id":360,"alpha2":"id","alpha3":"idn","name":"Indonesia"}, +{"id":364,"alpha2":"ir","alpha3":"irn","name":"Iran, Islamic Republic of"}, +{"id":368,"alpha2":"iq","alpha3":"irq","name":"Iraq"}, +{"id":372,"alpha2":"ie","alpha3":"irl","name":"Ireland"}, +{"id":376,"alpha2":"il","alpha3":"isr","name":"Israel"}, +{"id":380,"alpha2":"it","alpha3":"ita","name":"Italy"}, +{"id":388,"alpha2":"jm","alpha3":"jam","name":"Jamaica"}, +{"id":392,"alpha2":"jp","alpha3":"jpn","name":"Japan"}, +{"id":400,"alpha2":"jo","alpha3":"jor","name":"Jordan"}, +{"id":398,"alpha2":"kz","alpha3":"kaz","name":"Kazakhstan"}, +{"id":404,"alpha2":"ke","alpha3":"ken","name":"Kenya"}, +{"id":296,"alpha2":"ki","alpha3":"kir","name":"Kiribati"}, +{"id":408,"alpha2":"kp","alpha3":"prk","name":"Korea, Democratic People's Republic of"}, +{"id":410,"alpha2":"kr","alpha3":"kor","name":"Korea, Republic of"}, +{"id":414,"alpha2":"kw","alpha3":"kwt","name":"Kuwait"}, +{"id":417,"alpha2":"kg","alpha3":"kgz","name":"Kyrgyzstan"}, +{"id":418,"alpha2":"la","alpha3":"lao","name":"Lao People's Democratic Republic"}, +{"id":428,"alpha2":"lv","alpha3":"lva","name":"Latvia"}, +{"id":422,"alpha2":"lb","alpha3":"lbn","name":"Lebanon"}, +{"id":426,"alpha2":"ls","alpha3":"lso","name":"Lesotho"}, +{"id":430,"alpha2":"lr","alpha3":"lbr","name":"Liberia"}, +{"id":434,"alpha2":"ly","alpha3":"lby","name":"Libya"}, +{"id":438,"alpha2":"li","alpha3":"lie","name":"Liechtenstein"}, +{"id":440,"alpha2":"lt","alpha3":"ltu","name":"Lithuania"}, +{"id":442,"alpha2":"lu","alpha3":"lux","name":"Luxembourg"}, +{"id":450,"alpha2":"mg","alpha3":"mdg","name":"Madagascar"}, +{"id":454,"alpha2":"mw","alpha3":"mwi","name":"Malawi"}, +{"id":458,"alpha2":"my","alpha3":"mys","name":"Malaysia"}, +{"id":462,"alpha2":"mv","alpha3":"mdv","name":"Maldives"}, +{"id":466,"alpha2":"ml","alpha3":"mli","name":"Mali"}, +{"id":470,"alpha2":"mt","alpha3":"mlt","name":"Malta"}, +{"id":584,"alpha2":"mh","alpha3":"mhl","name":"Marshall Islands"}, +{"id":478,"alpha2":"mr","alpha3":"mrt","name":"Mauritania"}, +{"id":480,"alpha2":"mu","alpha3":"mus","name":"Mauritius"}, +{"id":484,"alpha2":"mx","alpha3":"mex","name":"Mexico"}, +{"id":583,"alpha2":"fm","alpha3":"fsm","name":"Micronesia, Federated States of"}, +{"id":498,"alpha2":"md","alpha3":"mda","name":"Moldova, Republic of"}, +{"id":492,"alpha2":"mc","alpha3":"mco","name":"Monaco"}, +{"id":496,"alpha2":"mn","alpha3":"mng","name":"Mongolia"}, +{"id":499,"alpha2":"me","alpha3":"mne","name":"Montenegro"}, +{"id":504,"alpha2":"ma","alpha3":"mar","name":"Morocco"}, +{"id":508,"alpha2":"mz","alpha3":"moz","name":"Mozambique"}, +{"id":104,"alpha2":"mm","alpha3":"mmr","name":"Myanmar"}, +{"id":516,"alpha2":"na","alpha3":"nam","name":"Namibia"}, +{"id":520,"alpha2":"nr","alpha3":"nru","name":"Nauru"}, +{"id":524,"alpha2":"np","alpha3":"npl","name":"Nepal"}, +{"id":528,"alpha2":"nl","alpha3":"nld","name":"Netherlands"}, +{"id":554,"alpha2":"nz","alpha3":"nzl","name":"New Zealand"}, +{"id":558,"alpha2":"ni","alpha3":"nic","name":"Nicaragua"}, +{"id":562,"alpha2":"ne","alpha3":"ner","name":"Niger"}, +{"id":566,"alpha2":"ng","alpha3":"nga","name":"Nigeria"}, +{"id":807,"alpha2":"mk","alpha3":"mkd","name":"North Macedonia"}, +{"id":578,"alpha2":"no","alpha3":"nor","name":"Norway"}, +{"id":512,"alpha2":"om","alpha3":"omn","name":"Oman"}, +{"id":586,"alpha2":"pk","alpha3":"pak","name":"Pakistan"}, +{"id":585,"alpha2":"pw","alpha3":"plw","name":"Palau"}, +{"id":591,"alpha2":"pa","alpha3":"pan","name":"Panama"}, +{"id":598,"alpha2":"pg","alpha3":"png","name":"Papua New Guinea"}, +{"id":600,"alpha2":"py","alpha3":"pry","name":"Paraguay"}, +{"id":604,"alpha2":"pe","alpha3":"per","name":"Peru"}, +{"id":608,"alpha2":"ph","alpha3":"phl","name":"Philippines"}, +{"id":616,"alpha2":"pl","alpha3":"pol","name":"Poland"}, +{"id":620,"alpha2":"pt","alpha3":"prt","name":"Portugal"}, +{"id":634,"alpha2":"qa","alpha3":"qat","name":"Qatar"}, +{"id":642,"alpha2":"ro","alpha3":"rou","name":"Romania"}, +{"id":643,"alpha2":"ru","alpha3":"rus","name":"Russian Federation"}, +{"id":646,"alpha2":"rw","alpha3":"rwa","name":"Rwanda"}, +{"id":659,"alpha2":"kn","alpha3":"kna","name":"Saint Kitts and Nevis"}, +{"id":662,"alpha2":"lc","alpha3":"lca","name":"Saint Lucia"}, +{"id":670,"alpha2":"vc","alpha3":"vct","name":"Saint Vincent and the Grenadines"}, +{"id":882,"alpha2":"ws","alpha3":"wsm","name":"Samoa"}, +{"id":674,"alpha2":"sm","alpha3":"smr","name":"San Marino"}, +{"id":678,"alpha2":"st","alpha3":"stp","name":"Sao Tome and Principe"}, +{"id":682,"alpha2":"sa","alpha3":"sau","name":"Saudi Arabia"}, +{"id":686,"alpha2":"sn","alpha3":"sen","name":"Senegal"}, +{"id":688,"alpha2":"rs","alpha3":"srb","name":"Serbia"}, +{"id":690,"alpha2":"sc","alpha3":"syc","name":"Seychelles"}, +{"id":694,"alpha2":"sl","alpha3":"sle","name":"Sierra Leone"}, +{"id":702,"alpha2":"sg","alpha3":"sgp","name":"Singapore"}, +{"id":703,"alpha2":"sk","alpha3":"svk","name":"Slovakia"}, +{"id":705,"alpha2":"si","alpha3":"svn","name":"Slovenia"}, +{"id":90,"alpha2":"sb","alpha3":"slb","name":"Solomon Islands"}, +{"id":706,"alpha2":"so","alpha3":"som","name":"Somalia"}, +{"id":710,"alpha2":"za","alpha3":"zaf","name":"South Africa"}, +{"id":728,"alpha2":"ss","alpha3":"ssd","name":"South Sudan"}, +{"id":724,"alpha2":"es","alpha3":"esp","name":"Spain"}, +{"id":144,"alpha2":"lk","alpha3":"lka","name":"Sri Lanka"}, +{"id":729,"alpha2":"sd","alpha3":"sdn","name":"Sudan"}, +{"id":740,"alpha2":"sr","alpha3":"sur","name":"Suriname"}, +{"id":752,"alpha2":"se","alpha3":"swe","name":"Sweden"}, +{"id":756,"alpha2":"ch","alpha3":"che","name":"Switzerland"}, +{"id":760,"alpha2":"sy","alpha3":"syr","name":"Syrian Arab Republic"}, +{"id":762,"alpha2":"tj","alpha3":"tjk","name":"Tajikistan"}, +{"id":834,"alpha2":"tz","alpha3":"tza","name":"Tanzania, United Republic of"}, +{"id":764,"alpha2":"th","alpha3":"tha","name":"Thailand"}, +{"id":626,"alpha2":"tl","alpha3":"tls","name":"Timor-Leste"}, +{"id":768,"alpha2":"tg","alpha3":"tgo","name":"Togo"}, +{"id":776,"alpha2":"to","alpha3":"ton","name":"Tonga"}, +{"id":780,"alpha2":"tt","alpha3":"tto","name":"Trinidad and Tobago"}, +{"id":788,"alpha2":"tn","alpha3":"tun","name":"Tunisia"}, +{"id":792,"alpha2":"tr","alpha3":"tur","name":"Türkiye"}, +{"id":795,"alpha2":"tm","alpha3":"tkm","name":"Turkmenistan"}, +{"id":798,"alpha2":"tv","alpha3":"tuv","name":"Tuvalu"}, +{"id":800,"alpha2":"ug","alpha3":"uga","name":"Uganda"}, +{"id":804,"alpha2":"ua","alpha3":"ukr","name":"Ukraine"}, +{"id":784,"alpha2":"ae","alpha3":"are","name":"United Arab Emirates"}, +{"id":826,"alpha2":"gb","alpha3":"gbr","name":"United Kingdom of Great Britain and Northern Ireland"}, +{"id":840,"alpha2":"us","alpha3":"usa","name":"United States of America"}, +{"id":858,"alpha2":"uy","alpha3":"ury","name":"Uruguay"}, +{"id":860,"alpha2":"uz","alpha3":"uzb","name":"Uzbekistan"}, +{"id":548,"alpha2":"vu","alpha3":"vut","name":"Vanuatu"}, +{"id":862,"alpha2":"ve","alpha3":"ven","name":"Venezuela, Bolivarian Republic of"}, +{"id":704,"alpha2":"vn","alpha3":"vnm","name":"Viet Nam"}, +{"id":887,"alpha2":"ye","alpha3":"yem","name":"Yemen"}, +{"id":894,"alpha2":"zm","alpha3":"zmb","name":"Zambia"}, +{"id":716,"alpha2":"zw","alpha3":"zwe","name":"Zimbabwe"} +]'''; + +const String _countriesRuJson = '''[ +{"id":36,"alpha2":"au","alpha3":"aus","name":"Австралия"}, +{"id":40,"alpha2":"at","alpha3":"aut","name":"Австрия"}, +{"id":31,"alpha2":"az","alpha3":"aze","name":"Азербайджан"}, +{"id":8,"alpha2":"al","alpha3":"alb","name":"Албания"}, +{"id":12,"alpha2":"dz","alpha3":"dza","name":"Алжир"}, +{"id":24,"alpha2":"ao","alpha3":"ago","name":"Ангола"}, +{"id":20,"alpha2":"ad","alpha3":"and","name":"Андорра"}, +{"id":28,"alpha2":"ag","alpha3":"atg","name":"Антигуа и Барбуда"}, +{"id":32,"alpha2":"ar","alpha3":"arg","name":"Аргентина"}, +{"id":51,"alpha2":"am","alpha3":"arm","name":"Армения"}, +{"id":4,"alpha2":"af","alpha3":"afg","name":"Афганистан"}, +{"id":44,"alpha2":"bs","alpha3":"bhs","name":"Багамские Острова"}, +{"id":50,"alpha2":"bd","alpha3":"bgd","name":"Бангладеш"}, +{"id":52,"alpha2":"bb","alpha3":"brb","name":"Барбадос"}, +{"id":48,"alpha2":"bh","alpha3":"bhr","name":"Бахрейн"}, +{"id":84,"alpha2":"bz","alpha3":"blz","name":"Белиз"}, +{"id":112,"alpha2":"by","alpha3":"blr","name":"Беларусь"}, +{"id":56,"alpha2":"be","alpha3":"bel","name":"Бельгия"}, +{"id":204,"alpha2":"bj","alpha3":"ben","name":"Бенин"}, +{"id":100,"alpha2":"bg","alpha3":"bgr","name":"Болгария"}, +{"id":68,"alpha2":"bo","alpha3":"bol","name":"Боливия"}, +{"id":70,"alpha2":"ba","alpha3":"bih","name":"Босния и Герцеговина"}, +{"id":72,"alpha2":"bw","alpha3":"bwa","name":"Ботсвана"}, +{"id":76,"alpha2":"br","alpha3":"bra","name":"Бразилия"}, +{"id":96,"alpha2":"bn","alpha3":"brn","name":"Бруней"}, +{"id":854,"alpha2":"bf","alpha3":"bfa","name":"Буркина-Фасо"}, +{"id":108,"alpha2":"bi","alpha3":"bdi","name":"Бурунди"}, +{"id":64,"alpha2":"bt","alpha3":"btn","name":"Бутан"}, +{"id":548,"alpha2":"vu","alpha3":"vut","name":"Вануату"}, +{"id":826,"alpha2":"gb","alpha3":"gbr","name":"Великобритания"}, +{"id":348,"alpha2":"hu","alpha3":"hun","name":"Венгрия"}, +{"id":862,"alpha2":"ve","alpha3":"ven","name":"Венесуэла"}, +{"id":626,"alpha2":"tl","alpha3":"tls","name":"Восточный Тимор"}, +{"id":704,"alpha2":"vn","alpha3":"vnm","name":"Вьетнам"}, +{"id":266,"alpha2":"ga","alpha3":"gab","name":"Габон"}, +{"id":332,"alpha2":"ht","alpha3":"hti","name":"Гаити"}, +{"id":328,"alpha2":"gy","alpha3":"guy","name":"Гайана"}, +{"id":270,"alpha2":"gm","alpha3":"gmb","name":"Гамбия"}, +{"id":288,"alpha2":"gh","alpha3":"gha","name":"Гана"}, +{"id":320,"alpha2":"gt","alpha3":"gtm","name":"Гватемала"}, +{"id":324,"alpha2":"gn","alpha3":"gin","name":"Гвинея"}, +{"id":624,"alpha2":"gw","alpha3":"gnb","name":"Гвинея-Бисау"}, +{"id":276,"alpha2":"de","alpha3":"deu","name":"Германия"}, +{"id":340,"alpha2":"hn","alpha3":"hnd","name":"Гондурас"}, +{"id":308,"alpha2":"gd","alpha3":"grd","name":"Гренада"}, +{"id":300,"alpha2":"gr","alpha3":"grc","name":"Греция"}, +{"id":268,"alpha2":"ge","alpha3":"geo","name":"Грузия"}, +{"id":208,"alpha2":"dk","alpha3":"dnk","name":"Дания"}, +{"id":262,"alpha2":"dj","alpha3":"dji","name":"Джибути"}, +{"id":212,"alpha2":"dm","alpha3":"dma","name":"Доминика"}, +{"id":214,"alpha2":"do","alpha3":"dom","name":"Доминиканская Республика"}, +{"id":180,"alpha2":"cd","alpha3":"cod","name":"ДР Конго"}, +{"id":818,"alpha2":"eg","alpha3":"egy","name":"Египет"}, +{"id":894,"alpha2":"zm","alpha3":"zmb","name":"Замбия"}, +{"id":716,"alpha2":"zw","alpha3":"zwe","name":"Зимбабве"}, +{"id":376,"alpha2":"il","alpha3":"isr","name":"Израиль"}, +{"id":356,"alpha2":"in","alpha3":"ind","name":"Индия"}, +{"id":360,"alpha2":"id","alpha3":"idn","name":"Индонезия"}, +{"id":400,"alpha2":"jo","alpha3":"jor","name":"Иордания"}, +{"id":368,"alpha2":"iq","alpha3":"irq","name":"Ирак"}, +{"id":364,"alpha2":"ir","alpha3":"irn","name":"Иран"}, +{"id":372,"alpha2":"ie","alpha3":"irl","name":"Ирландия"}, +{"id":352,"alpha2":"is","alpha3":"isl","name":"Исландия"}, +{"id":724,"alpha2":"es","alpha3":"esp","name":"Испания"}, +{"id":380,"alpha2":"it","alpha3":"ita","name":"Италия"}, +{"id":887,"alpha2":"ye","alpha3":"yem","name":"Йемен"}, +{"id":132,"alpha2":"cv","alpha3":"cpv","name":"Кабо-Верде"}, +{"id":398,"alpha2":"kz","alpha3":"kaz","name":"Казахстан"}, +{"id":116,"alpha2":"kh","alpha3":"khm","name":"Камбоджа"}, +{"id":120,"alpha2":"cm","alpha3":"cmr","name":"Камерун"}, +{"id":124,"alpha2":"ca","alpha3":"can","name":"Канада"}, +{"id":634,"alpha2":"qa","alpha3":"qat","name":"Катар"}, +{"id":404,"alpha2":"ke","alpha3":"ken","name":"Кения"}, +{"id":196,"alpha2":"cy","alpha3":"cyp","name":"Кипр"}, +{"id":417,"alpha2":"kg","alpha3":"kgz","name":"Кыргызстан"}, +{"id":296,"alpha2":"ki","alpha3":"kir","name":"Кирибати"}, +{"id":408,"alpha2":"kp","alpha3":"prk","name":"КНДР (Корейская Народно-Демократическая Республика)"}, +{"id":156,"alpha2":"cn","alpha3":"chn","name":"Китай (Китайская Народная Республика)"}, +{"id":170,"alpha2":"co","alpha3":"col","name":"Колумбия"}, +{"id":174,"alpha2":"km","alpha3":"com","name":"Коморы"}, +{"id":188,"alpha2":"cr","alpha3":"cri","name":"Коста-Рика"}, +{"id":384,"alpha2":"ci","alpha3":"civ","name":"Кот-д’Ивуар"}, +{"id":192,"alpha2":"cu","alpha3":"cub","name":"Куба"}, +{"id":414,"alpha2":"kw","alpha3":"kwt","name":"Кувейт"}, +{"id":418,"alpha2":"la","alpha3":"lao","name":"Лаос"}, +{"id":428,"alpha2":"lv","alpha3":"lva","name":"Латвия"}, +{"id":426,"alpha2":"ls","alpha3":"lso","name":"Лесото"}, +{"id":430,"alpha2":"lr","alpha3":"lbr","name":"Либерия"}, +{"id":422,"alpha2":"lb","alpha3":"lbn","name":"Ливан"}, +{"id":434,"alpha2":"ly","alpha3":"lby","name":"Ливия"}, +{"id":440,"alpha2":"lt","alpha3":"ltu","name":"Литва"}, +{"id":438,"alpha2":"li","alpha3":"lie","name":"Лихтенштейн"}, +{"id":442,"alpha2":"lu","alpha3":"lux","name":"Люксембург"}, +{"id":480,"alpha2":"mu","alpha3":"mus","name":"Маврикий"}, +{"id":478,"alpha2":"mr","alpha3":"mrt","name":"Мавритания"}, +{"id":450,"alpha2":"mg","alpha3":"mdg","name":"Мадагаскар"}, +{"id":807,"alpha2":"mk","alpha3":"mkd","name":"Северная Македония"}, +{"id":454,"alpha2":"mw","alpha3":"mwi","name":"Малави"}, +{"id":458,"alpha2":"my","alpha3":"mys","name":"Малайзия"}, +{"id":466,"alpha2":"ml","alpha3":"mli","name":"Мали"}, +{"id":462,"alpha2":"mv","alpha3":"mdv","name":"Мальдивы"}, +{"id":470,"alpha2":"mt","alpha3":"mlt","name":"Мальта"}, +{"id":504,"alpha2":"ma","alpha3":"mar","name":"Марокко"}, +{"id":584,"alpha2":"mh","alpha3":"mhl","name":"Маршалловы Острова"}, +{"id":484,"alpha2":"mx","alpha3":"mex","name":"Мексика"}, +{"id":583,"alpha2":"fm","alpha3":"fsm","name":"Микронезия"}, +{"id":508,"alpha2":"mz","alpha3":"moz","name":"Мозамбик"}, +{"id":498,"alpha2":"md","alpha3":"mda","name":"Молдова"}, +{"id":492,"alpha2":"mc","alpha3":"mco","name":"Монако"}, +{"id":496,"alpha2":"mn","alpha3":"mng","name":"Монголия"}, +{"id":104,"alpha2":"mm","alpha3":"mmr","name":"Мьянма"}, +{"id":516,"alpha2":"na","alpha3":"nam","name":"Намибия"}, +{"id":520,"alpha2":"nr","alpha3":"nru","name":"Науру"}, +{"id":524,"alpha2":"np","alpha3":"npl","name":"Непал"}, +{"id":562,"alpha2":"ne","alpha3":"ner","name":"Нигер"}, +{"id":566,"alpha2":"ng","alpha3":"nga","name":"Нигерия"}, +{"id":528,"alpha2":"nl","alpha3":"nld","name":"Нидерланды"}, +{"id":558,"alpha2":"ni","alpha3":"nic","name":"Никарагуа"}, +{"id":554,"alpha2":"nz","alpha3":"nzl","name":"Новая Зеландия"}, +{"id":578,"alpha2":"no","alpha3":"nor","name":"Норвегия"}, +{"id":784,"alpha2":"ae","alpha3":"are","name":"ОАЭ"}, +{"id":512,"alpha2":"om","alpha3":"omn","name":"Оман"}, +{"id":586,"alpha2":"pk","alpha3":"pak","name":"Пакистан"}, +{"id":585,"alpha2":"pw","alpha3":"plw","name":"Палау"}, +{"id":591,"alpha2":"pa","alpha3":"pan","name":"Панама"}, +{"id":598,"alpha2":"pg","alpha3":"png","name":"Папуа — Новая Гвинея"}, +{"id":600,"alpha2":"py","alpha3":"pry","name":"Парагвай"}, +{"id":604,"alpha2":"pe","alpha3":"per","name":"Перу"}, +{"id":616,"alpha2":"pl","alpha3":"pol","name":"Польша"}, +{"id":620,"alpha2":"pt","alpha3":"prt","name":"Португалия"}, +{"id":178,"alpha2":"cg","alpha3":"cog","name":"Республика Конго"}, +{"id":410,"alpha2":"kr","alpha3":"kor","name":"Республика Корея"}, +{"id":643,"alpha2":"ru","alpha3":"rus","name":"Россия"}, +{"id":646,"alpha2":"rw","alpha3":"rwa","name":"Руанда"}, +{"id":642,"alpha2":"ro","alpha3":"rou","name":"Румыния"}, +{"id":222,"alpha2":"sv","alpha3":"slv","name":"Сальвадор"}, +{"id":882,"alpha2":"ws","alpha3":"wsm","name":"Самоа"}, +{"id":674,"alpha2":"sm","alpha3":"smr","name":"Сан-Марино"}, +{"id":678,"alpha2":"st","alpha3":"stp","name":"Сан-Томе и Принсипи"}, +{"id":682,"alpha2":"sa","alpha3":"sau","name":"Саудовская Аравия"}, +{"id":690,"alpha2":"sc","alpha3":"syc","name":"Сейшельские Острова"}, +{"id":686,"alpha2":"sn","alpha3":"sen","name":"Сенегал"}, +{"id":670,"alpha2":"vc","alpha3":"vct","name":"Сент-Винсент и Гренадины"}, +{"id":659,"alpha2":"kn","alpha3":"kna","name":"Сент-Китс и Невис"}, +{"id":662,"alpha2":"lc","alpha3":"lca","name":"Сент-Люсия"}, +{"id":688,"alpha2":"rs","alpha3":"srb","name":"Сербия"}, +{"id":702,"alpha2":"sg","alpha3":"sgp","name":"Сингапур"}, +{"id":760,"alpha2":"sy","alpha3":"syr","name":"Сирия"}, +{"id":703,"alpha2":"sk","alpha3":"svk","name":"Словакия"}, +{"id":705,"alpha2":"si","alpha3":"svn","name":"Словения"}, +{"id":90,"alpha2":"sb","alpha3":"slb","name":"Соломоновы Острова"}, +{"id":706,"alpha2":"so","alpha3":"som","name":"Сомали"}, +{"id":729,"alpha2":"sd","alpha3":"sdn","name":"Судан"}, +{"id":740,"alpha2":"sr","alpha3":"sur","name":"Суринам"}, +{"id":840,"alpha2":"us","alpha3":"usa","name":"США"}, +{"id":694,"alpha2":"sl","alpha3":"sle","name":"Сьерра-Леоне"}, +{"id":762,"alpha2":"tj","alpha3":"tjk","name":"Таджикистан"}, +{"id":764,"alpha2":"th","alpha3":"tha","name":"Таиланд"}, +{"id":834,"alpha2":"tz","alpha3":"tza","name":"Танзания"}, +{"id":768,"alpha2":"tg","alpha3":"tgo","name":"Того"}, +{"id":776,"alpha2":"to","alpha3":"ton","name":"Тонга"}, +{"id":780,"alpha2":"tt","alpha3":"tto","name":"Тринидад и Тобаго"}, +{"id":798,"alpha2":"tv","alpha3":"tuv","name":"Тувалу"}, +{"id":788,"alpha2":"tn","alpha3":"tun","name":"Тунис"}, +{"id":795,"alpha2":"tm","alpha3":"tkm","name":"Туркменистан"}, +{"id":792,"alpha2":"tr","alpha3":"tur","name":"Турция"}, +{"id":800,"alpha2":"ug","alpha3":"uga","name":"Уганда"}, +{"id":860,"alpha2":"uz","alpha3":"uzb","name":"Узбекистан"}, +{"id":804,"alpha2":"ua","alpha3":"ukr","name":"Украина"}, +{"id":858,"alpha2":"uy","alpha3":"ury","name":"Уругвай"}, +{"id":242,"alpha2":"fj","alpha3":"fji","name":"Фиджи"}, +{"id":608,"alpha2":"ph","alpha3":"phl","name":"Филиппины"}, +{"id":246,"alpha2":"fi","alpha3":"fin","name":"Финляндия"}, +{"id":250,"alpha2":"fr","alpha3":"fra","name":"Франция"}, +{"id":191,"alpha2":"hr","alpha3":"hrv","name":"Хорватия"}, +{"id":140,"alpha2":"cf","alpha3":"caf","name":"ЦАР"}, +{"id":148,"alpha2":"td","alpha3":"tcd","name":"Чад"}, +{"id":499,"alpha2":"me","alpha3":"mne","name":"Черногория"}, +{"id":203,"alpha2":"cz","alpha3":"cze","name":"Чехия"}, +{"id":152,"alpha2":"cl","alpha3":"chl","name":"Чили"}, +{"id":756,"alpha2":"ch","alpha3":"che","name":"Швейцария"}, +{"id":752,"alpha2":"se","alpha3":"swe","name":"Швеция"}, +{"id":144,"alpha2":"lk","alpha3":"lka","name":"Шри-Ланка"}, +{"id":218,"alpha2":"ec","alpha3":"ecu","name":"Эквадор"}, +{"id":226,"alpha2":"gq","alpha3":"gnq","name":"Экваториальная Гвинея"}, +{"id":232,"alpha2":"er","alpha3":"eri","name":"Эритрея"}, +{"id":748,"alpha2":"sz","alpha3":"swz","name":"Эсватини"}, +{"id":233,"alpha2":"ee","alpha3":"est","name":"Эстония"}, +{"id":231,"alpha2":"et","alpha3":"eth","name":"Эфиопия"}, +{"id":710,"alpha2":"za","alpha3":"zaf","name":"ЮАР"}, +{"id":728,"alpha2":"ss","alpha3":"ssd","name":"Южный Судан"}, +{"id":388,"alpha2":"jm","alpha3":"jam","name":"Ямайка"}, +{"id":392,"alpha2":"jp","alpha3":"jpn","name":"Япония"} +]'''; diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index e69de29..75cc983 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -0,0 +1,356 @@ +/// All protocol operation codes. +/// +/// Naming follows the server-side convention. +/// Use [Opcode.name] to get a human-readable label for logging. +/// +/// файл писла нейронка (я че ебанутый чтоль чтобы вручную хуярить опкоды и их значения) +abstract class Opcode { + // ── Session ──────────────────────────────────────────────────────── + static const int ping = 1; // Пинг + static const int debug = 2; // Отладка / понг + static const int reconnect = 3; // Реконнект + static const int log = 5; // Аналитика / события + static const int sessionInit = 6; // Инициализация сессии (хэндшейк) + + // ── Profile ──────────────────────────────────────────────────────── + static const int profile = 16; // Обновление профиля + + // ── Auth ─────────────────────────────────────────────────────────── + static const int authRequest = 17; // Запрос OTP-кода + static const int auth = 18; // Проверка OTP-кода + static const int login = 19; // Вход (загрузка чатов/контактов) + static const int logout = 20; // Выход из аккаунта + static const int sync = 21; // Синхронизация данных + static const int config = 22; // Настройки приватности / конфиг + static const int authConfirm = 23; // Завершение регистрации + + // ── Auth 2FA ─────────────────────────────────────────────────────── + static const int authLoginRestorePassword = 101; // Восстановление пароля + static const int auth2faDetails = 104; // Детали 2FA + static const int externalCallback = 105; // Внешний коллбэк + static const int authValidatePassword = 107; // Валидация пароля + static const int authValidateHint = 108; // Валидация подсказки пароля + static const int authVerifyEmail = 109; // Верификация email + static const int authCheckEmail = 110; // Проверка email + static const int authSet2fa = 111; // Установка 2FA + static const int authCreateTrack = 112; // Создание трека авторизации + static const int authCheckPassword = 113; // Проверка пароля при регистрации + static const int authLoginCheckPassword = 115; // Проверка пароля при входе + static const int authLoginProfileDelete = 116; // Удаление профиля при входе + + // ── Assets (стикеры) ─────────────────────────────────────────────── + static const int presetAvatars = 25; // Пресетные аватарки + static const int assetsGet = 26; // Получение стикерпаков + static const int assetsUpdate = 27; // Синхронизация стикеров + static const int assetsGetByIds = 28; // Стикерпаки по ID + static const int assetsAdd = 29; // Добавление стикерпака + static const int assetsRemove = 259; // Удаление стикерпака + static const int assetsMove = 260; // Перемещение стикерпака + static const int assetsListModify = 261; // Изменение списка стикерпаков + + // ── Contacts ─────────────────────────────────────────────────────── + static const int contactInfo = 32; // Информация о контакте + static const int contactAdd = 33; // Добавление контакта + static const int contactUpdate = 34; // Обновление контакта (блок и т.д.) + static const int contactPresence = 35; // Запрос присутствия по ID + static const int contactList = 36; // Список заблокированных + static const int contactSearch = 37; // Поиск контакта + static const int contactMutual = 38; // Общие контакты + static const int contactPhotos = 39; // Фото контакта + static const int contactSort = 40; // Сортировка контактов + static const int contactVerify = 42; // Верификация контакта + static const int removeContactPhoto = 43; // Удаление фото контакта + static const int contactInfoByPhone = 46; // Поиск контакта по номеру + + // ── Chats ────────────────────────────────────────────────────────── + static const int chatInfo = 48; // Информация о чате / создание группы + static const int chatHistory = 49; // История сообщений + static const int chatMark = 50; // Отметка прочитанным + static const int chatMedia = 51; // Медиа чата + static const int chatDelete = 52; // Удаление чата + static const int chatsList = 53; // Список чатов + static const int chatClear = 54; // Очистка истории чата + static const int chatUpdate = 55; // Обновление настроек чата + static const int chatCheckLink = 56; // Проверка ссылки чата + static const int chatJoin = 57; // Вступление в группу / канал + static const int chatLeave = 58; // Выход из чата + static const int chatMembers = 59; // Участники группы + static const int publicSearch = 60; // Глобальный поиск + static const int chatPersonalConfig = 61; // Персональные настройки чата + static const int chatCreate = 63; // Создание чата + + // ── Messages ─────────────────────────────────────────────────────── + static const int msgSend = 64; // Отправка сообщения + static const int msgTyping = 65; // Индикатор набора текста + static const int msgDelete = 66; // Удаление сообщения + static const int msgEdit = 67; // Редактирование сообщения + static const int chatSearch = 68; // Поиск по чату + static const int msgSharePreview = 70; // Превью ссылки + static const int msgGet = 71; // Получение сообщения по ID + static const int msgSearchTouch = 72; // Точечный поиск сообщений + static const int msgSearch = 73; // Поиск сообщений + static const int msgGetStat = 74; // Статистика сообщения + static const int chatSubscribe = 75; // Подписка на обновления чата + static const int msgDeleteRange = 92; // Удаление диапазона сообщений + + // ── Reactions ────────────────────────────────────────────────────── + static const int msgReaction = 178; // Отправка реакции + static const int msgCancelReaction = 179; // Отмена реакции + static const int msgGetReactions = 180; // Получение реакций + static const int msgGetDetailedReactions = 181; // Детальные реакции + static const int chatReactionsSettingsSet = 257; // Установка настроек реакций + static const int reactionsSettingsGetByChatId = 258; // Настройки реакций чата + + // ── Calls & Video ────────────────────────────────────────────────── + static const int videoChatStart = 76; // Начало видеочата + static const int chatMembersUpdate = 77; // Обновление участников / добавление + static const int videoChatStartActive = 78; // Инициация активного звонка + static const int videoChatHistory = 79; // История звонков + static const int videoChatCreateJoinLink = 84; // Ссылка для входа в видеочат + static const int videoChatMembers = 195; // Участники видеочата + static const int getInboundCalls = 103; // Входящие звонки + + // ── Media & Files ────────────────────────────────────────────────── + static const int photoUpload = 80; // Загрузка фото + static const int stickerUpload = 81; // Загрузка стикера + static const int videoUpload = 82; // Загрузка видео + static const int videoPlay = 83; // URL видео + static const int chatPinSetVisibility = 86; // Видимость закрепов + static const int fileUpload = 87; // Загрузка файла + static const int fileDownload = 88; // Скачивание файла + static const int linkInfo = 89; // Информация по ссылке / вход в канал + static const int audioPlay = 301; // Воспроизведение аудио + + // ── Sessions ─────────────────────────────────────────────────────── + static const int sessionsInfo = 96; // Запрос активных сессий + static const int sessionsClose = 97; // Закрытие всех сессий + static const int phoneBind_request = 98; // Запрос привязки телефона + static const int phoneBindConfirm = 99; // Подтверждение привязки телефона + + // ── Bots ─────────────────────────────────────────────────────────── + static const int chatComplain = 117; // Жалоба на чат + static const int msgSendCallback = 118; // Коллбэк бота + static const int suspendBot = 119; // Приостановка бота + static const int chatBotCommands = 144; // Команды бота + static const int botInfo = 145; // Информация о боте + + // ── Location ─────────────────────────────────────────────────────── + static const int locationStop = 124; // Остановка трансляции геолокации + + // ── Mentions ─────────────────────────────────────────────────────── + static const int getLastMentions = 127; // Последние упоминания + + // ── Stickers (creation) ──────────────────────────────────────────── + static const int stickerCreate = 193; // Создание стикера + static const int stickerSuggest = 194; // Предложение стикера + + // ── Notifications (push от сервера) ──────────────────────────────── + static const int notifMessage = 128; // Новое сообщение + static const int notifTyping = 129; // Индикатор набора + static const int notifMark = 130; // Прочитано + static const int notifContact = 131; // Обновление контакта + static const int notifPresence = 132; // Статус онлайн + static const int notifConfig = 134; // Обновление конфига + static const int notifChat = 135; // Обновление чата + static const int notifAttach = 136; // Загрузка файла завершена + static const int notifCallStart = 137; // Входящий звонок + static const int notifContactSort = 139; // Пересортировка контактов + static const int notifMsgDeleteRange = 140; // Удаление диапазона + static const int notifMsgDelete = 142; // Удаление сообщения + static const int notifCallbackAnswer = 143; // Ответ бота + static const int notifLocation = 147; // Обновление геолокации + static const int notifLocationRequest = 148; // Запрос геолокации + static const int notifAssetsUpdate = 150; // Обновление стикеров + static const int notifDraft = 152; // Черновик + static const int notifDraftDiscard = 153; // Сброс черновика + static const int notifMsgDelayed = 154; // Отложенное сообщение + static const int notifMsgReactionsChanged = 155; // Изменение реакций + static const int notifMsgYouReacted = 156; // Ваша реакция + static const int notifProfile = 159; // Обновление профиля + static const int notifBanners = 292; // Баннеры + static const int notifFolders = 277; // Обновление папок + + // ── Misc ─────────────────────────────────────────────────────────── + static const int okToken = 158; // OK-токен + static const int webAppInitData = 160; // Данные WebApp + static const int complain = 161; // Жалоба + static const int complainReasonsGet = 162; // Причины жалобы + static const int draftSave = 176; // Сохранение черновика + static const int draftDiscard = 177; // Удаление черновика + static const int chatHide = 196; // Скрытие чата + static const int chatSearchCommonParticipants = 198; // Общие участники + static const int profileDelete = 199; // Удаление профиля + static const int profileDeleteTime = 200; // Таймер удаления профиля + static const int authQrApprove = 290; // Подтверждение QR-входа + static const int chatSuggest = 300; // Предложения чатов + + // ── Polls ────────────────────────────────────────────────────────── + static const int sendVote = 304; // Голосование + static const int votersListByAnswer = 305; // Список голосовавших + static const int getPollUpdates = 306; // Обновления опроса + + // ── Folders ──────────────────────────────────────────────────────── + static const int foldersGet = 272; // Получение папок + static const int foldersGetById = 273; // Папка по ID + static const int foldersUpdate = 274; // Обновление / создание папки + static const int foldersReorder = 275; // Сортировка папок + static const int foldersDelete = 276; // Удаление папки + + // ── Human-readable names ─────────────────────────────────────────── + + static String name(int opcode) => _names[opcode] ?? 'UNKNOWN($opcode)'; + + static const Map _names = { + ping: 'PING', + debug: 'DEBUG', + reconnect: 'RECONNECT', + log: 'LOG', + sessionInit: 'SESSION_INIT', + profile: 'PROFILE', + authRequest: 'AUTH_REQUEST', + auth: 'AUTH', + login: 'LOGIN', + logout: 'LOGOUT', + sync: 'SYNC', + config: 'CONFIG', + authConfirm: 'AUTH_CONFIRM', + authLoginRestorePassword: 'AUTH_LOGIN_RESTORE_PASSWORD', + auth2faDetails: 'AUTH_2FA_DETAILS', + externalCallback: 'EXTERNAL_CALLBACK', + authValidatePassword: 'AUTH_VALIDATE_PASSWORD', + authValidateHint: 'AUTH_VALIDATE_HINT', + authVerifyEmail: 'AUTH_VERIFY_EMAIL', + authCheckEmail: 'AUTH_CHECK_EMAIL', + authSet2fa: 'AUTH_SET_2FA', + authCreateTrack: 'AUTH_CREATE_TRACK', + authCheckPassword: 'AUTH_CHECK_PASSWORD', + authLoginCheckPassword: 'AUTH_LOGIN_CHECK_PASSWORD', + authLoginProfileDelete: 'AUTH_LOGIN_PROFILE_DELETE', + presetAvatars: 'PRESET_AVATARS', + assetsGet: 'ASSETS_GET', + assetsUpdate: 'ASSETS_UPDATE', + assetsGetByIds: 'ASSETS_GET_BY_IDS', + assetsAdd: 'ASSETS_ADD', + assetsRemove: 'ASSETS_REMOVE', + assetsMove: 'ASSETS_MOVE', + assetsListModify: 'ASSETS_LIST_MODIFY', + contactInfo: 'CONTACT_INFO', + contactAdd: 'CONTACT_ADD', + contactUpdate: 'CONTACT_UPDATE', + contactPresence: 'CONTACT_PRESENCE', + contactList: 'CONTACT_LIST', + contactSearch: 'CONTACT_SEARCH', + contactMutual: 'CONTACT_MUTUAL', + contactPhotos: 'CONTACT_PHOTOS', + contactSort: 'CONTACT_SORT', + contactVerify: 'CONTACT_VERIFY', + removeContactPhoto: 'REMOVE_CONTACT_PHOTO', + contactInfoByPhone: 'CONTACT_INFO_BY_PHONE', + chatInfo: 'CHAT_INFO', + chatHistory: 'CHAT_HISTORY', + chatMark: 'CHAT_MARK', + chatMedia: 'CHAT_MEDIA', + chatDelete: 'CHAT_DELETE', + chatsList: 'CHATS_LIST', + chatClear: 'CHAT_CLEAR', + chatUpdate: 'CHAT_UPDATE', + chatCheckLink: 'CHAT_CHECK_LINK', + chatJoin: 'CHAT_JOIN', + chatLeave: 'CHAT_LEAVE', + chatMembers: 'CHAT_MEMBERS', + publicSearch: 'PUBLIC_SEARCH', + chatPersonalConfig: 'CHAT_PERSONAL_CONFIG', + chatCreate: 'CHAT_CREATE', + msgSend: 'MSG_SEND', + msgTyping: 'MSG_TYPING', + msgDelete: 'MSG_DELETE', + msgEdit: 'MSG_EDIT', + chatSearch: 'CHAT_SEARCH', + msgSharePreview: 'MSG_SHARE_PREVIEW', + msgGet: 'MSG_GET', + msgSearchTouch: 'MSG_SEARCH_TOUCH', + msgSearch: 'MSG_SEARCH', + msgGetStat: 'MSG_GET_STAT', + chatSubscribe: 'CHAT_SUBSCRIBE', + msgDeleteRange: 'MSG_DELETE_RANGE', + msgReaction: 'MSG_REACTION', + msgCancelReaction: 'MSG_CANCEL_REACTION', + msgGetReactions: 'MSG_GET_REACTIONS', + msgGetDetailedReactions: 'MSG_GET_DETAILED_REACTIONS', + chatReactionsSettingsSet: 'CHAT_REACTIONS_SETTINGS_SET', + reactionsSettingsGetByChatId: 'REACTIONS_SETTINGS_GET_BY_CHAT_ID', + videoChatStart: 'VIDEO_CHAT_START', + chatMembersUpdate: 'CHAT_MEMBERS_UPDATE', + videoChatStartActive: 'VIDEO_CHAT_START_ACTIVE', + videoChatHistory: 'VIDEO_CHAT_HISTORY', + videoChatCreateJoinLink: 'VIDEO_CHAT_CREATE_JOIN_LINK', + videoChatMembers: 'VIDEO_CHAT_MEMBERS', + getInboundCalls: 'GET_INBOUND_CALLS', + photoUpload: 'PHOTO_UPLOAD', + stickerUpload: 'STICKER_UPLOAD', + videoUpload: 'VIDEO_UPLOAD', + videoPlay: 'VIDEO_PLAY', + chatPinSetVisibility: 'CHAT_PIN_SET_VISIBILITY', + fileUpload: 'FILE_UPLOAD', + fileDownload: 'FILE_DOWNLOAD', + linkInfo: 'LINK_INFO', + audioPlay: 'AUDIO_PLAY', + sessionsInfo: 'SESSIONS_INFO', + sessionsClose: 'SESSIONS_CLOSE', + phoneBind_request: 'PHONE_BIND_REQUEST', + phoneBindConfirm: 'PHONE_BIND_CONFIRM', + chatComplain: 'CHAT_COMPLAIN', + msgSendCallback: 'MSG_SEND_CALLBACK', + suspendBot: 'SUSPEND_BOT', + chatBotCommands: 'CHAT_BOT_COMMANDS', + botInfo: 'BOT_INFO', + locationStop: 'LOCATION_STOP', + getLastMentions: 'GET_LAST_MENTIONS', + stickerCreate: 'STICKER_CREATE', + stickerSuggest: 'STICKER_SUGGEST', + notifMessage: 'NOTIF_MESSAGE', + notifTyping: 'NOTIF_TYPING', + notifMark: 'NOTIF_MARK', + notifContact: 'NOTIF_CONTACT', + notifPresence: 'NOTIF_PRESENCE', + notifConfig: 'NOTIF_CONFIG', + notifChat: 'NOTIF_CHAT', + notifAttach: 'NOTIF_ATTACH', + notifCallStart: 'NOTIF_CALL_START', + notifContactSort: 'NOTIF_CONTACT_SORT', + notifMsgDeleteRange: 'NOTIF_MSG_DELETE_RANGE', + notifMsgDelete: 'NOTIF_MSG_DELETE', + notifCallbackAnswer: 'NOTIF_CALLBACK_ANSWER', + notifLocation: 'NOTIF_LOCATION', + notifLocationRequest: 'NOTIF_LOCATION_REQUEST', + notifAssetsUpdate: 'NOTIF_ASSETS_UPDATE', + notifDraft: 'NOTIF_DRAFT', + notifDraftDiscard: 'NOTIF_DRAFT_DISCARD', + notifMsgDelayed: 'NOTIF_MSG_DELAYED', + notifMsgReactionsChanged: 'NOTIF_MSG_REACTIONS_CHANGED', + notifMsgYouReacted: 'NOTIF_MSG_YOU_REACTED', + notifProfile: 'NOTIF_PROFILE', + notifBanners: 'NOTIF_BANNERS', + notifFolders: 'NOTIF_FOLDERS', + okToken: 'OK_TOKEN', + webAppInitData: 'WEB_APP_INIT_DATA', + complain: 'COMPLAIN', + complainReasonsGet: 'COMPLAIN_REASONS_GET', + draftSave: 'DRAFT_SAVE', + draftDiscard: 'DRAFT_DISCARD', + chatHide: 'CHAT_HIDE', + chatSearchCommonParticipants: 'CHAT_SEARCH_COMMON_PARTICIPANTS', + profileDelete: 'PROFILE_DELETE', + profileDeleteTime: 'PROFILE_DELETE_TIME', + authQrApprove: 'AUTH_QR_APPROVE', + chatSuggest: 'CHAT_SUGGEST', + sendVote: 'SEND_VOTE', + votersListByAnswer: 'VOTERS_LIST_BY_ANSWER', + getPollUpdates: 'GET_POLL_UPDATES', + foldersGet: 'FOLDERS_GET', + foldersGetById: 'FOLDERS_GET_BY_ID', + foldersUpdate: 'FOLDERS_UPDATE', + foldersReorder: 'FOLDERS_REORDER', + foldersDelete: 'FOLDERS_DELETE', + }; +} diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 34fc2ba..d7c95f9 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -4,132 +4,170 @@ import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; import '../utils/logger.dart'; -final int apiVersion = 10; -int seq = 1; +/// ver(1) + cmd(2) + seq(1) + opcode(2) + packedLen(4) = 10 +const int headerSize = 10; +const int _maxDecompressedSize = 1048576; // 1 MB -/* - Базовый класс пакета +/// Типы команд в протоколе +abstract class CmdType { + static const int request = 0; // запрос клиента + static const int push = 1; // пуш от сервера + static const int ok = 0x100; // ответ: ок + static const int notFound = 0x200; // ответ: не найдено + static const int error = 0x300; // ответ: ошибка +} - api - версия API (по умолчанию 10 для сокета) - cmd - от кого был прислан пакет ( - 0 - клиент (если отправляется серверу) - 1 - сервер - 3 - отправляется вместе с ошибкой) - seq - счёт пакетов (начинается с 1) - opcode - показывает, для чего отправляется пакет (подробнее в /lib/core/protocol/opcode_map.dart) - payload - содержимое пакета (отправляется с сервера ввиде msgpack) -*/ +/// Бинарный пакет +/// +/// Формат заголовка (10 байт): +/// ``` +/// [0] ver — версия протокола (uint8) +/// [1..2] cmd — тип команды (uint16 BE) +/// [3] seq — порядковый номер 0..255 (uint8) +/// [4..5] opcode — код операции (uint16 BE) +/// [6..9] packedLen — флаг сжатия [6] + длина payload [7..9] (uint32 BE) +/// [10..] payload — данные в MsgPack, опционально сжатые LZ4 +/// ``` class Packet { int api; int cmd; int seq; int opcode; - Map payload; + dynamic payload; Packet({ this.api = 10, this.cmd = 0, - this.seq = 1, + this.seq = 0, this.opcode = 0, - this.payload = const {}, + this.payload, }); - /// Выводит пакет в консоль - void printPacket() { - logger.i( - "Api: $api\nCmd: $cmd\nSeq: $seq\nOPCode: $opcode\nPayload: $payload", - ); - } + bool get isOk => cmd == CmdType.ok; + bool get isError => cmd == CmdType.error; + bool get isPush => cmd == CmdType.push; + + @override + String toString() => + 'Packet(ver=$api cmd=$cmd seq=$seq opcode=$opcode payload=$payload)'; } -/// Функция запаковки пакета -/// Принимает только opcode и payload, так как api и cmd -/// константы (10 и 0 соответственно), а seq считается менеджером сокета -/// Возращает запакованный пакет -Uint8List packPacket(int opcode, Map payload) { - // Объяснение каждой переменной смотри в классе Packet +/// Упаковка пакета для отправки на сервер +Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { + final header = ByteData(headerSize); + header.setUint8(0, 10); + header.setUint16(1, CmdType.request, Endian.big); + header.setUint8(3, seq); + header.setUint16(4, opcode, Endian.big); - final apiVerB = Uint8List(1)..[0] = apiVersion; - final cmdB = Uint8List(1)..[0] = 0; - final seqB = Uint8List(2)..buffer.asByteData().setUint16(0, seq, Endian.big); - final opcodeB = Uint8List(2) - ..buffer.asByteData().setUint16(0, opcode, Endian.big); - - // Перед получением длины пакуем в msgpack final payloadBytes = msgpack.serialize(payload); final payloadLen = payloadBytes.length & 0xFFFFFF; + header.setUint32(6, payloadLen, Endian.big); - final payloadLenB = Uint8List(4) - ..buffer.asByteData().setUint32(0, payloadLen, Endian.big); - - return Uint8List.fromList( - apiVerB + cmdB + seqB + opcodeB + payloadLenB + payloadBytes, - ); + return Uint8List.fromList(header.buffer.asUint8List() + payloadBytes); } -/// Функция распаковки пакета -/// Принимает байты пакета, которые отправляет сервер -/// Возращает Packet +/// Распаковка пакета от сервера Packet unpackPacket(Uint8List packet) { - // Для удобства расшифровки пакета переводим в ByteData - ByteData packetData = ByteData.view(packet.buffer); + final data = ByteData.view( + packet.buffer, + packet.offsetInBytes, + packet.lengthInBytes, + ); - // Объяснение каждой переменной смотри в классе Packet + final apiVer = data.getUint8(0); + final cmd = data.getUint16(1, Endian.big); + final seq = data.getUint8(3); + final opcode = data.getUint16(4, Endian.big); + final packedLen = data.getUint32(6, Endian.big); - // API версия и cmd представляют из себя 8 битные числа - var apiVer = packetData.getUint8(0) & 0xFF; - var cmd = packetData.getUint8(1) & 0xFF; + final compFlag = packedLen >> 24; + final payloadLength = packedLen & 0xFFFFFF; - // Sequence и OPCode представляют из себя 16 битные числа - var seq = packetData.getUint16(2) & 0xFFFF; - var opcode = packetData.getUint16(4) & 0xFFFF; + var payloadBytes = Uint8List.sublistView( + packet, + headerSize, + headerSize + payloadLength, + ); + dynamic payload; - // После базовых переменных идет длина пакета, является 32 битным числом - var packedLen = packetData.getUint32(6); - - // Compression flag показывает, сжат ли payload - var compFlag = packedLen >> 24; - - // Длина payload'а - var payloadLength = packedLen & 0xFFFFFF; - - // Байты payload'а, могут быть сжаты LZ4 - var payloadBytes = packet.buffer.asUint8List(10, payloadLength); - - var payload = {}; - - // Если payload пустой, ничего не делаем (так может быть при получении пинга) - if (payloadBytes.buffer.lengthInBytes > 0) { - // Если пакет сжат, используем LZ4 + if (payloadBytes.isNotEmpty) { if (compFlag != 0) { try { - final decompressedBytes = lz4Decompress( + payloadBytes = lz4Decompress( payloadBytes, - decompressedSize: 99999, + decompressedSize: _maxDecompressedSize, ); - - payloadBytes = decompressedBytes; - } catch (e) { - logger.e("Ошибка при декомпрессировании: $e", error: e); + } catch (_) { + // dart_lz4 не умеет block-формат, фолбэк на ручной декомпрессор + try { + payloadBytes = _lz4BlockDecompress(payloadBytes, _maxDecompressedSize); + } catch (e) { + logger.e("Ошибка декомпрессии LZ4: $e", error: e); + } } } - // Пробуем десериализовать msgpack try { payload = msgpack.deserialize(payloadBytes); } catch (e) { - logger.e("Ошибка при десериализации msgpack: $e", error: e); + logger.e("Ошибка десериализации MsgPack: $e", error: e); } } - // Собираем целый пакет и возращаем его - final fullPacket = Packet( + return Packet( api: apiVer, cmd: cmd, seq: seq, opcode: opcode, payload: payload, ); +} - return fullPacket; +/// LZ4 block декомпрессия (без frame-заголовка). +/// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает. +Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { + final dst = BytesBuilder(copy: false); + int pos = 0; + + while (pos < src.length) { + final token = src[pos++]; + var litLen = token >> 4; + + if (litLen == 15) { + while (pos < src.length) { + final b = src[pos++]; + litLen += b; + if (b != 255) break; + } + } + + if (litLen > 0) { + dst.add(src.sublist(pos, pos + litLen)); + pos += litLen; + } + + if (pos >= src.length) break; + + final offset = src[pos] | (src[pos + 1] << 8); + pos += 2; + if (offset == 0) throw StateError('LZ4: offset = 0'); + + var matchLen = (token & 0x0F) + 4; + if ((token & 0x0F) == 0x0F) { + while (pos < src.length) { + final b = src[pos++]; + matchLen += b; + if (b != 255) break; + } + } + + final out = dst.toBytes(); + final start = out.length - offset; + dst.add(List.generate(matchLen, (i) => out[start + (i % offset)])); + + if (dst.length > maxSize) throw StateError('LZ4: превышен лимит'); + } + + return dst.toBytes(); } diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index e69de29..16c4444 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../utils/logger.dart'; + +enum SocketState { disconnected, connecting, connected } + +/// Обёртка над TCP + TLS сокетом. +/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. +class Connection { + SecureSocket? _socket; + SocketState _state = SocketState.disconnected; + + final _dataController = StreamController.broadcast(); + final _stateController = StreamController.broadcast(); + + Stream get dataStream => _dataController.stream; + Stream get stateStream => _stateController.stream; + SocketState get state => _state; + bool get isConnected => _state == SocketState.connected; + + void _setState(SocketState newState) { + if (_state == newState) return; + _state = newState; + _stateController.add(newState); + } + + Future connect(String host, int port) async { + if (_state != SocketState.disconnected) return; + _setState(SocketState.connecting); + + try { + final socket = await Socket.connect(host, port); + _socket = await SecureSocket.secure( + socket, + onBadCertificate: (_) => true, + ); + + _setState(SocketState.connected); + logger.i('Подключено к $host:$port'); + + _socket!.listen( + (data) => _dataController.add(Uint8List.fromList(data)), + onError: (Object error) { + logger.e('Ошибка сокета: $error'); + disconnect(); + }, + onDone: () { + logger.w('Сокет закрыт сервером'); + disconnect(); + }, + ); + } catch (e) { + logger.e('Не удалось подключиться: $e'); + _setState(SocketState.disconnected); + rethrow; + } + } + + void write(Uint8List data) { + if (_socket == null || !isConnected) { + throw StateError('Нельзя писать: сокет не подключён'); + } + _socket!.add(data); + } + + Future disconnect() async { + final socket = _socket; + _socket = null; + + if (socket != null) { + try { + socket.destroy(); + } catch (_) {} + } + + _setState(SocketState.disconnected); + } + + void dispose() { + disconnect(); + _dataController.close(); + _stateController.close(); + } +} diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index e69de29..efeec15 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -0,0 +1,115 @@ +import 'dart:async'; + +import '../protocol/packet.dart'; +import '../protocol/opcode_map.dart'; +import '../utils/logger.dart'; + +typedef PacketHandler = void Function(Packet packet); + +/// Роутер входящих пакетов. +/// +/// Ответы на запросы матчатся по seq (через [registerPending]), +/// пуши — по opcode (через [registerHandler]). +class PacketDispatcher { + final Map> _pendingRequests = {}; + final Map _requestTimestamps = {}; + final Map _pushHandlers = {}; + + final _pushController = StreamController.broadcast(); + + /// Стрим всех входящих пушей (cmd == 1) + Stream get pushStream => _pushController.stream; + + Timer? _cleanupTimer; + + PacketDispatcher() { + _cleanupTimer = Timer.periodic( + const Duration(seconds: 10), + (_) => _cleanupStaleRequests(), + ); + } + + /// Регистрирует ожидание ответа — future завершится когда + /// придёт пакет с совпадающим seq. + Future registerPending(int seq) { + final completer = Completer(); + _pendingRequests[seq] = completer; + _requestTimestamps[seq] = DateTime.now(); + return completer.future; + } + + /// Вешает обработчик на пуши с конкретным опкодом. + void registerHandler(int opcode, PacketHandler handler) { + _pushHandlers[opcode] = handler; + } + + void unregisterHandler(int opcode) { + _pushHandlers.remove(opcode); + } + + void dispatch(Packet packet) { + final tag = Opcode.name(packet.opcode); + + if (packet.cmd == CmdType.ok || + packet.cmd == CmdType.error || + packet.cmd == CmdType.notFound) { + final status = packet.isOk ? 'OK' : packet.isError ? 'ERR' : 'NOT_FOUND'; + logger.i('<= [$tag] seq=${packet.seq} $status\n payload: ${packet.payload}'); + + final completer = _pendingRequests.remove(packet.seq); + _requestTimestamps.remove(packet.seq); + + if (completer == null) { + if (packet.opcode != Opcode.ping) { + logger.w('Нет ожидающего запроса для seq=${packet.seq} [$tag]'); + } + return; + } + + if (packet.isError) { + completer.completeError(packet); + } else { + completer.complete(packet); + } + } else if (packet.isPush) { + logger.i('<= push [$tag] ${packet.payload}'); + _pushHandlers[packet.opcode]?.call(packet); + _pushController.add(packet); + } + } + + /// Чистит зависшие запросы старше 30 секунд + void _cleanupStaleRequests() { + final now = DateTime.now(); + final staleKeys = []; + + _requestTimestamps.forEach((seq, ts) { + if (now.difference(ts).inSeconds > 30) staleKeys.add(seq); + }); + + for (final seq in staleKeys) { + final completer = _pendingRequests.remove(seq); + _requestTimestamps.remove(seq); + if (completer != null && !completer.isCompleted) { + completer.completeError(TimeoutException('Таймаут запроса seq=$seq')); + } + } + } + + /// Обрывает все ожидающие запросы (при дисконнекте) + void clearPending() { + for (final entry in _pendingRequests.entries) { + if (!entry.value.isCompleted) { + entry.value.completeError(StateError('Соединение закрыто')); + } + } + _pendingRequests.clear(); + _requestTimestamps.clear(); + } + + void dispose() { + _cleanupTimer?.cancel(); + clearPending(); + _pushController.close(); + } +} diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index e69de29..25af729 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -0,0 +1,57 @@ +import 'dart:typed_data'; + +import '../protocol/packet.dart'; +import '../utils/logger.dart'; + +/// Буфер входящих данных. +/// Копит сырые байты из сокета, собирает из них целые пакеты. +class PacketReceiver { + Uint8List _buffer = Uint8List(0); + + static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта + + /// Добавляет байты в буфер, возвращает все собранные пакеты. + /// Неполные данные остаются в буфере до следующего вызова. + List feed(Uint8List data) { + final newBuffer = Uint8List(_buffer.length + data.length); + newBuffer.setAll(0, _buffer); + newBuffer.setAll(_buffer.length, data); + _buffer = newBuffer; + + if (_buffer.length > _maxBufferSize) { + logger.e('PacketReceiver: переполнение буфера (${_buffer.length} B), сброс'); + reset(); + return []; + } + + final packets = []; + + while (_buffer.length >= headerSize) { + final bd = ByteData.view( + _buffer.buffer, + _buffer.offsetInBytes, + headerSize, + ); + final packedLen = bd.getUint32(6, Endian.big); + final payloadLength = packedLen & 0xFFFFFF; + final totalLength = headerSize + payloadLength; + + if (_buffer.length < totalLength) break; + + final packetBytes = Uint8List.sublistView(_buffer, 0, totalLength); + _buffer = _buffer.sublist(totalLength); + + try { + packets.add(unpackPacket(packetBytes)); + } catch (e) { + logger.e('PacketReceiver: ошибка распаковки: $e'); + } + } + + return packets; + } + + void reset() { + _buffer = Uint8List(0); + } +} diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index e69de29..31a5c2c 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -0,0 +1,25 @@ +import '../protocol/packet.dart'; +import '../protocol/opcode_map.dart'; +import '../utils/logger.dart'; +import 'connection.dart'; + +/// Упаковывает и отправляет пакеты, ведёт счётчик seq. +class PacketSender { + int _seq = 0; + + int get currentSeq => _seq; + + int _nextSeq() { + _seq = (_seq + 1) % 256; + return _seq; + } + + /// Отправляет пакет, возвращает присвоенный seq. + int send(Connection connection, int opcode, Map payload) { + final seq = _nextSeq(); + final data = packPacket(opcode, payload, seq: seq); + connection.write(data); + logger.i('=> [${Opcode.name(opcode)}] seq=$seq\n payload: $payload'); + return seq; + } +} diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 5fbcd7b..580e134 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -3,7 +3,7 @@ import 'package:logger/logger.dart'; final logger = Logger( // устанавливает минимальный уровень логов. // доступны уровни: all, trace, debug, info, warning, error, fatal, off - level: Level.info, + level: Level.all, printer: PrettyPrinter( methodCount: 0, errorMethodCount: 5, diff --git a/lib/main.dart b/lib/main.dart index 244a702..429dfbf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,11 @@ import 'package:flutter/material.dart'; +import 'backend/api.dart'; -void main() { +final api = Api(); + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await api.connect(); runApp(const MyApp()); } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..08c3ab1 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.server + com.apple.security.network.client + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..ee95ab7 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client + From 8f51e4f3a7213393f309065132ccac5c49d02a59 Mon Sep 17 00:00:00 2001 From: noxzion Date: Sun, 15 Mar 2026 18:34:57 +0500 Subject: [PATCH 005/323] =?UTF-8?q?=D0=A7=D1=82=D0=BE-=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=85=D0=BE=D0=B6=D0=B5=D0=B5=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/komet.png | Bin 0 -> 179772 bytes .../auth/code_confirmation_screen.dart | 228 ++++++++ lib/frontend/screens/auth/login_screen.dart | 491 ++++++++++++++++++ .../screens/chats/chat_list_screen.dart | 478 +++++++++++++++++ lib/main.dart | 113 +--- pubspec.lock | 210 +++++++- pubspec.yaml | 4 + 7 files changed, 1419 insertions(+), 105 deletions(-) create mode 100644 assets/komet.png create mode 100644 lib/frontend/screens/auth/code_confirmation_screen.dart diff --git a/assets/komet.png b/assets/komet.png new file mode 100644 index 0000000000000000000000000000000000000000..8c251b96dcf3e268f9cef5f2d2cd10745f77c2df GIT binary patch literal 179772 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 z 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 z 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 Vz1ZOo#>R(&oVj?q;-pW~{{zZv1@8a= literal 0 HcmV?d00001 diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart new file mode 100644 index 0000000..076f4f3 --- /dev/null +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -0,0 +1,228 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../chats/chat_list_screen.dart'; + +class CodeConfirmationScreen extends StatefulWidget { + final String phoneNumber; + + const CodeConfirmationScreen({super.key, required this.phoneNumber}); + + @override + State createState() => _CodeConfirmationScreenState(); +} + +class _CodeConfirmationScreenState extends State { + final TextEditingController _codeController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + int _timerSeconds = 30; + Timer? _timer; + + @override + void initState() { + super.initState(); + _startTimer(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _timer?.cancel(); + _codeController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _startTimer() { + _timer?.cancel(); + _timerSeconds = 30; + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + setState(() { + if (_timerSeconds > 0) { + _timerSeconds--; + } else { + _timer?.cancel(); + } + }); + }); + } + + void _resendCode() { + if (_timerSeconds == 0) { + _startTimer(); + // TODO: Implement actual resend logic here + print('Resending code to ${widget.phoneNumber}'); + } + } + + void _navigateToChats() { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const ChatListScreen()), + (route) => false, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0D0D12), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white70), + onPressed: () => Navigator.pop(context), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text( + widget.phoneNumber, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + const SizedBox(height: 12), + const Text( + 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.', + style: TextStyle( + color: Colors.white54, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 1.4, + ), + ), + const SizedBox(height: 12), + Stack( + children: [ + Opacity( + opacity: 0, + child: SizedBox( + height: 0, + width: 0, + child: TextField( + controller: _codeController, + focusNode: _focusNode, + keyboardType: TextInputType.number, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(5), + ], + onChanged: (value) { + setState(() {}); + if (value.length == 5) { + _navigateToChats(); + } + }, + ), + ), + ), + + GestureDetector( + onTap: () => _focusNode.requestFocus(), + child: FittedBox( + child: Row( + children: List.generate(5, (index) { + bool isFocused = _codeController.text.length == index && _focusNode.hasFocus; + bool hasValue = _codeController.text.length > index; + String char = hasValue ? _codeController.text[index] : ''; + + return Container( + width: 44, + height: 54, + margin: EdgeInsets.only(right: index == 4 ? 0 : 10), + decoration: BoxDecoration( + color: const Color(0xFF1E1E2A), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isFocused + ? const Color(0xFFBEC2FF) + : (hasValue ? Colors.white24 : Colors.transparent), + width: 1.5, + ), + ), + alignment: Alignment.center, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + transitionBuilder: (Widget child, Animation animation) { + return ScaleTransition( + scale: animation, + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: Text( + char, + key: ValueKey(char + index.toString()), + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + }), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + GestureDetector( + onTap: _resendCode, + child: Text( + _timerSeconds > 0 + ? 'Отправить повторно через $_timerSeconds сек.' + : 'Отправить код по SMS', + style: const TextStyle( + color: Color(0xFF63A9F5), + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + onPressed: () { + if (_codeController.text.length == 5) { + _navigateToChats(); + } + }, + backgroundColor: _codeController.text.length == 5 + ? const Color(0xffc1c4ff) + : const Color(0xFF1E1E2A), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + child: Icon( + Icons.arrow_forward, + color: _codeController.text.length == 5 ? Colors.black : Colors.white24, + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index e69de29..298ebd3 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -0,0 +1,491 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'code_confirmation_screen.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final TextEditingController _phoneController = TextEditingController(); + String? _errorText; + + void _showTOS(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1E1E2A), + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 1.0, + snap: true, + snapSizes: const [0.7, 1.0], + expand: false, + builder: (context, scrollController) => Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Условия использования', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Symbols.close, color: Colors.white70), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: ListView( + controller: scrollController, + children: const [ + Text( + 'Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"\n\n' + '1. Статус и отношения\n' + '1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX").\n' + '1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».\n' + '1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.\n\n' + '2. Условия использования\n' + '2.1. Используя Приложение «Komet», вы:\n' + '• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)\n' + '• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;\n' + '• Принимаете на себя все риски, связанные с использованием Приложения.\n' + '2.2. Строго запрещено:\n' + '• Использовать Приложение «Komet» для распространения запрещённого контента;\n' + '• Осуществлять массовые рассылки (спам);\n' + '• Нарушать законодательство РФ и международное право;\n' + '• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».\n' + '2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.\n' + '2.4. Особенности технического взаимодействия:\n' + '• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)\n' + '• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;\n' + '• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.\n\n' + '3. Технические аспекты\n' + '3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.\n' + '3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.\n' + '3.3. Разработчики не несут ответственности за:\n' + '• Изменения в API оригинального сервиса;\n' + '• Блокировку аккаунтов пользователей;\n' + '• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».\n\n' + '4. Конфиденциальность\n' + '4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.\n' + '4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».\n' + '4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.\n\n' + '5. Ответственность и ограничения\n' + '5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.\n' + '5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.\n\n' + '6. Правовые основания\n' + '6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:\n' + '• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;\n' + '• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;\n' + '• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;\n' + '• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.\n' + '6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:\n' + '• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;\n' + '• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;\n' + '• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.\n' + '6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.\n\n' + '7. Заключительные положения\n' + '7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:\n' + '• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;\n' + '• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;\n' + '• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.\n' + '7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.\n\n' + '8. Функции безопасности и конфиденциальности\n' + '8.1. Приложение «Komet» включает инструменты защиты приватности:\n' + '• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT);\n' + '• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n' + '• Ограничение телеметрии — для минимизации передачи диагностических данных.\n' + '8.2. Данные функции:\n' + '• Направлены исключительно на защиту конфиденциальности пользователей;\n' + '• Не используются для обхода систем безопасности оригинального сервиса;\n' + '• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n' + '8.3. Разработчики не несут ответственности за:\n' + '• Блокировки, связанные с использованием инструментов конфиденциальности;\n' + '• Изменения в работе сервиса при активации данных функций.\n' + '8.4. Функции экспорта и импорта сессии\n' + '8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n' + '• Обеспечения переносимости данных между устройствами пользователя\n' + '• Резервного копирования учетных данных\n' + '• Восстановления доступа при утере устройства\n' + '8.4.2. Особенности реализации:\n' + '• Экспорт сессии осуществляется без привязки к номеру телефона\n' + '• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n' + '• Ключ шифрования известен только пользователю и не сохраняется в приложении\n' + '8.4.3. Техническая реализация экспорта сессии:\n' + '• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n' + '• Используется подмена параметров сессии для сохранения контекста аутентификации\n' + '• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n' + '• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n' + '• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n' + '• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n' + '8.4.4. Правовые основания:\n' + '• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n' + '• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n' + '• Принцип минимизации данных — сбор только необходимой для работы информации\n' + '• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n' + '• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n' + '• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n' + '8.4.5. Ограничения ответственности:\n' + '• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n' + '• Разработчики не имеют доступа к зашифрованным данным сессии\n' + '• Восстановление утерянных паролей невозможно в целях безопасности\n' + '• Ключи шифрования не хранятся в приложении и известны только пользователю', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.5, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + void _validateAndSubmit() { + setState(() { + final phone = _phoneController.text.replaceAll(RegExp(r'\D'), ''); + if (phone.isEmpty) { + _errorText = 'Пожалуйста, введите номер телефона'; + } else if (phone.length < 10) { + _errorText = 'Номер телефона слишком короткий'; + } else { + _errorText = null; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CodeConfirmationScreen( + phoneNumber: '+7 ${_phoneController.text}', + ), + ), + ); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0D0D12), + body: GestureDetector( + behavior: HitTestBehavior.translucent, + onVerticalDragEnd: (details) { + if (details.primaryVelocity != null && details.primaryVelocity! < -500) { + _showTOS(context); + } + }, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + onPressed: () {}, + icon: const Icon( + Symbols.admin_panel_settings, + color: Colors.white70, + weight: 400, + ), + ), + IconButton( + onPressed: () {}, + icon: const Icon( + Symbols.language, + color: Colors.white70, + weight: 400, + ), + ), + ], + ), + const SizedBox(height: 24), + Center( + child: Column( + children: [ + Image.asset( + 'assets/komet.png', + height: 80, + color: Colors.white, + ), + const SizedBox(height: 16), + const Text( + 'Войдите в Komet', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + const Text( + 'Проверьте код страны и введите свой\nномер телефона.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white70, + fontSize: 13, + fontWeight: FontWeight.w400, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + _buildInputField( + label: 'Страна', + content: Row( + children: [ + const Text( + 'Россия', + style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), + ), + const Spacer(), + const Icon(Icons.keyboard_arrow_down, color: Colors.white70), + ], + ), + ), + const SizedBox(height: 12), + _buildInputField( + label: 'Номер телефона', + isError: _errorText != null, + errorText: _errorText, + content: Row( + children: [ + const Text( + '+7', + style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), + ), + const SizedBox(width: 12), + Container( + width: 1, + height: 24, + color: Colors.white24, + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + _PhoneInputFormatter(), + ], + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), + decoration: const InputDecoration( + hintText: '(000) 000-00-00', + hintStyle: TextStyle(color: Colors.white38, fontSize: 15, fontWeight: FontWeight.w400), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (value) { + if (_errorText != null) { + setState(() { + _errorText = null; + }); + } + }, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + TextButton( + onPressed: () {}, + child: const Text( + 'Другие способы входа', + style: TextStyle( + color: Color(0xFFAFAFFF), + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.4, + ), + ), + ), + const Spacer(), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: RichText( + text: TextSpan( + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + height: 1.4, + fontFamily: 'Outfit', + ), + children: [ + const TextSpan(text: 'Продолжая, вы соглашаетесь с\n'), + TextSpan( + text: 'пользовательскими соглашениями', + style: const TextStyle( + color: Color(0xFFAFAFFF), + fontWeight: FontWeight.w500, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => _showTOS(context), + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: FloatingActionButton( + onPressed: _validateAndSubmit, + backgroundColor: const Color(0xffc1c4ff), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + child: const Icon(Icons.arrow_forward, color: Colors.black), + ), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + + Widget _buildInputField({ + required String label, + required Widget content, + bool isError = false, + String? errorText, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: double.infinity, + height: 54, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border.all( + color: isError ? Colors.redAccent : const Color(0xFFBEC2FF), + width: 1.5, + ), + borderRadius: BorderRadius.circular(50), + ), + child: content, + ), + Positioned( + top: -10, + left: 20, + child: Container( + color: const Color(0xFF0D0D12), + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + label, + style: TextStyle( + color: isError ? Colors.redAccent : const Color(0xFFBEC2FF), + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ], + ), + if (isError && errorText != null) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + errorText, + style: const TextStyle( + color: Colors.redAccent, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ), + ], + ], + ); + } +} + +class _PhoneInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, TextEditingValue newValue) { + var text = newValue.text.replaceAll(RegExp(r'\D'), ''); + + if (newValue.text.length < oldValue.text.length) { + final oldDigits = oldValue.text.replaceAll(RegExp(r'\D'), ''); + if (text.length == oldDigits.length && text.isNotEmpty) { + text = text.substring(0, text.length - 1); + } + } + + if (text.length > 10) text = text.substring(0, 10); + + final buffer = StringBuffer(); + for (int i = 0; i < text.length; i++) { + if (i == 0) buffer.write('('); + buffer.write(text[i]); + if (i == 2) buffer.write(') '); + if (i == 5) buffer.write('-'); + } + + final formattedText = buffer.toString(); + return TextEditingValue( + text: formattedText, + selection: TextSelection.collapsed(offset: formattedText.length), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index e69de29..8288825 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -0,0 +1,478 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'dart:ui' as ui; + +class ChatListScreen extends StatefulWidget { + const ChatListScreen({super.key}); + + @override + State createState() => _ChatListScreenState(); +} + +class _ChatListScreenState extends State { + String _selectedCategory = 'Все чаты'; + int _currentNavIndex = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0D0D12), + body: SafeArea( + bottom: false, + child: Stack( + children: [ + CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Подключение...', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + IconButton( + onPressed: () {}, + icon: const Icon( + Symbols.more_vert, + color: Colors.white54, + weight: 400, + ), + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: SizedBox( + height: 96, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + _buildStoryItem('Даша', 'https://i.pravatar.cc/150?u=dasha', true), + _buildStoryItem('Мастика', 'https://i.pravatar.cc/150?u=mastika', false), + ], + ), + ), + ), + + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + sliver: SliverToBoxAdapter( + child: Container( + height: 44, + decoration: BoxDecoration( + color: const Color(0xFF1E1E2A), + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: const Row( + children: [ + Icon(Symbols.search, color: Colors.white38, size: 20, weight: 400), + SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle(color: Colors.white, fontSize: 15), + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle(color: Colors.white38, fontSize: 15), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), + ), + ), + + SliverToBoxAdapter( + child: SizedBox( + height: 48, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + ui.PointerDeviceKind.touch, + ui.PointerDeviceKind.mouse, + ui.PointerDeviceKind.trackpad, + }, + ), + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + physics: const BouncingScrollPhysics(), + children: [ + _buildCategoryChip('Все чаты'), + const SizedBox(width: 8), + _buildCategoryChip('Контакты'), + const SizedBox(width: 8), + _buildCategoryChip('Пидоры'), + const SizedBox(width: 8), + _buildCategoryChip('Каналы'), + const SizedBox(width: 8), + _buildCategoryChip('Группы'), + const SizedBox(width: 8), + _buildCategoryChip('Боты'), + const SizedBox(width: 8), + _buildCategoryChip('Избранное'), + const SizedBox(width: 8), + _buildCategoryChip('Архив'), + ], + ), + ), + ), + ), + SliverList( + delegate: SliverChildListDelegate([ + _buildChatItem( + 'Станислав', + 'Хорошо', + '10:07', + 'https://i.pravatar.cc/150?u=stas', + isOnline: true, + isRead: true, + ), + _buildChatItem( + 'Илья', + 'печатает...', + '10:07', + 'https://i.pravatar.cc/150?u=ilya', + isOnline: true, + isTyping: true, + unreadCount: 1, + ), + _buildChatItem( + 'Вероника', + 'Спасибо', + '09:56', + 'https://i.pravatar.cc/150?u=veronika', + isRead: true, + ), + _buildChatItem( + 'Komet Client', + 'Кстати. Смотрите, какую шту...', + '09:56', + 'https://i.pravatar.cc/150?u=komet', + unreadCount: 5, + isMuted: true, + ), + _buildChatItem( + '4-й подъезд', + 'Людмила: Сколько?', + '09:34', + 'https://i.pravatar.cc/150?u=podezd', + unreadCount: 78, + isMuted: true, + ), + const SizedBox(height: 100), + ]), + ), + ], + ), + Positioned( + left: 8, + right: 8, + bottom: 24, + child: Container( + height: 68, + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: const Color(0xFF1E1E28), + borderRadius: BorderRadius.circular(34), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: LayoutBuilder( + builder: (context, constraints) { + double totalWidth = constraints.maxWidth; + + double totalWeight = 5.2; + double unitWidth = totalWidth / totalWeight; + double activeWidth = unitWidth * 2.2; + double inactiveWidth = unitWidth * 1.0; + + double leftOffset = 0; + for (int i = 0; i < _currentNavIndex; i++) { + leftOffset += inactiveWidth; + } + + return Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + left: leftOffset + 4, + top: 8, + bottom: 8, + width: activeWidth - 8, + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFBEC2FF), + borderRadius: BorderRadius.circular(26), + ), + ), + ), + Row( + children: List.generate(4, (index) { + IconData icon; + String label; + switch (index) { + case 0: icon = Symbols.chat_bubble; label = 'Чаты'; break; + case 1: icon = Symbols.call; label = 'Звонки'; break; + case 2: icon = Symbols.person_pin; label = 'Контакты'; break; + default: icon = Symbols.settings; label = 'Настройки'; + } + + bool isSelected = _currentNavIndex == index; + return AnimatedContainer( + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + width: isSelected ? (activeWidth - 0.5) : (inactiveWidth - 0.5), + child: ClipRRect( + borderRadius: BorderRadius.circular(26), + child: _buildNavItem(index, icon, label), + ), + ); + }), + ), + ], + ); + }, + ), + ), + ), + Positioned( + right: 20, + bottom: 110, + child: FloatingActionButton( + onPressed: () {}, + backgroundColor: const Color(0xFFC1C4FF), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: const Icon(Symbols.add, color: Colors.black, size: 28, weight: 400), + ), + ), + ], + ), + ), + ); + } + + Widget _buildStoryItem(String name, String imageUrl, bool hasUpdate) { + return Padding( + padding: const EdgeInsets.only(right: 16), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(2.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: hasUpdate + ? Border.all(color: const Color(0xFFC1C4FF), width: 2) + : Border.all(color: Colors.white10), + ), + child: CircleAvatar( + radius: 26, + backgroundImage: NetworkImage(imageUrl), + ), + ), + const SizedBox(height: 6), + Text( + name, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildCategoryChip(String title) { + bool isSelected = _selectedCategory == title; + return GestureDetector( + onTap: () => setState(() => _selectedCategory = title), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFFC1C4FF) : const Color(0xFF1E1E2A), + borderRadius: BorderRadius.circular(15), + ), + child: Text( + title, + style: TextStyle( + color: isSelected ? Colors.black : Colors.white54, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + Widget _buildChatItem( + String name, + String message, + String time, + String imageUrl, { + bool isOnline = false, + bool isTyping = false, + bool isRead = false, + int unreadCount = 0, + bool isMuted = false, + }) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2), + leading: Stack( + children: [ + CircleAvatar( + radius: 24, + backgroundImage: NetworkImage(imageUrl), + ), + if (isOnline) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: const Color(0xFFC1C4FF), + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF0D0D12), width: 2), + ), + ), + ), + ], + ), + title: Row( + children: [ + Expanded( + child: Text( + name, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + if (isMuted) + const Icon(Symbols.notifications_off, color: Colors.white24, size: 14, weight: 400), + const SizedBox(width: 8), + Text( + time, + style: const TextStyle( + color: Colors.white38, + fontSize: 12, + ), + ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Expanded( + child: Text( + message, + style: TextStyle( + color: isTyping ? const Color(0xFFC1C4FF) : Colors.white54, + fontSize: 14, + fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (unreadCount > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isMuted ? Colors.white10 : const Color(0xFF1E1E2A), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + unreadCount.toString(), + style: TextStyle( + color: isMuted ? Colors.white38 : Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ) + else if (isRead) + const Icon(Symbols.done_all, color: Color(0xFFC1C4FF), size: 16, weight: 400), + ], + ), + ), + ); + } + + Widget _buildNavItem(int index, IconData icon, String label) { + bool isSelected = _currentNavIndex == index; + return GestureDetector( + onTap: () => setState(() => _currentNavIndex = index), + behavior: HitTestBehavior.opaque, + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + color: isSelected ? Colors.black : Colors.white, + size: 20, + weight: 400, + fill: isSelected ? 1.0 : 0.0, + ), + AnimatedContainer( + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + width: isSelected ? null : 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: isSelected ? 1.0 : 0.0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 4), + Text( + label, + style: const TextStyle( + color: Colors.black, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..8c52477 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/frontend/screens/auth/login_screen.dart'; void main() { runApp(const MyApp()); @@ -7,116 +9,19 @@ void main() { class MyApp extends StatelessWidget { const MyApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', + title: 'Komet', + debugShowCheckedModeBanner: false, theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], + useMaterial3: true, + brightness: Brightness.dark, + textTheme: GoogleFonts.outfitTextTheme( + ThemeData.dark().textTheme, ), ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), + home: const LoginScreen(), ); } } diff --git a/pubspec.lock b/pubspec.lock index 3e29bd2..150913d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -41,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -65,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -83,6 +115,46 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -123,6 +195,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -139,6 +219,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + material_symbols_icons: + dependency: "direct main" + description: + name: material_symbols_icons + sha256: c62b15f2b3de98d72cbff0148812f5ef5159f05e61fc9f9a089ec2bb234df082 + url: "https://pub.dev" + source: hosted + version: "4.2906.0" meta: dependency: transitive description: @@ -155,6 +243,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + url: "https://pub.dev" + source: hosted + version: "0.17.5" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" path: dependency: transitive description: @@ -163,6 +267,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" sky_engine: dependency: transitive description: flutter @@ -216,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -232,6 +416,30 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.10.4 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index a704a79..b832e79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: dart_lz4: ^1.0.0 msgpack_dart: ^1.0.1 logger: ^2.6.2 + google_fonts: ^6.2.1 + material_symbols_icons: ^4.2906.0 dev_dependencies: flutter_test: @@ -59,6 +61,8 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + assets: + - assets/komet.png # To add assets to your application, add an assets section, like this: # assets: From a72c9b40f90d14da3b5aba9ca97bf306177a2099 Mon Sep 17 00:00:00 2001 From: noxzion Date: Sun, 15 Mar 2026 20:08:38 +0500 Subject: [PATCH 006/323] =?UTF-8?q?=D0=9D=D0=B5=D0=B1=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D1=88=D0=BE=D0=B5=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 8288825..d2e7c66 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -112,21 +112,21 @@ class _ChatListScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), physics: const BouncingScrollPhysics(), children: [ - _buildCategoryChip('Все чаты'), + _buildFolderChip('Все чаты'), const SizedBox(width: 8), - _buildCategoryChip('Контакты'), + _buildFolderChip('Контакты'), const SizedBox(width: 8), - _buildCategoryChip('Пидоры'), + _buildFolderChip('Пидоры'), const SizedBox(width: 8), - _buildCategoryChip('Каналы'), + _buildFolderChip('Каналы'), const SizedBox(width: 8), - _buildCategoryChip('Группы'), + _buildFolderChip('Группы'), const SizedBox(width: 8), - _buildCategoryChip('Боты'), + _buildFolderChip('Боты'), const SizedBox(width: 8), - _buildCategoryChip('Избранное'), + _buildFolderChip('Избранное'), const SizedBox(width: 8), - _buildCategoryChip('Архив'), + _buildFolderChip('Архив'), ], ), ), @@ -306,7 +306,7 @@ class _ChatListScreenState extends State { ); } - Widget _buildCategoryChip(String title) { + Widget _buildFolderChip(String title) { bool isSelected = _selectedCategory == title; return GestureDetector( onTap: () => setState(() => _selectedCategory = title), From 4c758efba84a235bfffbdc0264a55c91a7426f1f Mon Sep 17 00:00:00 2001 From: InviseDivine Date: Sun, 15 Mar 2026 17:27:54 +0200 Subject: [PATCH 007/323] =?UTF-8?q?FIX:=20cmd=20=D0=B2=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=D0=B0=D1=85=20FEAT:=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20user=20agent'=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 + lib/backend/api.dart | 84 ++++++++++++++++++--- lib/core/protocol/packet.dart | 58 +++++++++------ pubspec.lock | 135 +++++++++++++++++++++++++++++++--- pubspec.yaml | 3 + 5 files changed, 239 insertions(+), 44 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..08303a5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cmake.sourceDirectory": "/run/media/invisedivine/Drive/! My projects/Komet/linux/runner" +} \ No newline at end of file diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 92f4e2c..f014ad8 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -10,7 +10,18 @@ import '../core/transport/receiver.dart'; import '../core/transport/sender.dart'; import '../core/utils/logger.dart'; -enum SessionState { disconnected, connecting, connected, online } +import 'package:device_info_plus/device_info_plus.dart'; +import 'dart:io'; +import 'package:timezone/data/latest_all.dart' as tz; +import 'package:timezone/timezone.dart' as tz; +import 'package:flutter_timezone/flutter_timezone.dart'; + +enum SessionState { + disconnected, + connecting, + connected, + online +} /// Клиент API. /// @@ -39,7 +50,7 @@ class Api { /// Подключается к серверу, шлёт хэндшейк, запускает пинг. Future connect() async { if (_sessionState != SessionState.disconnected) return; - + // Ставим автоматический реконнект и статус подключения _autoReconnect = true; _setSessionState(SessionState.connecting); @@ -88,27 +99,75 @@ class Api { } /// Отправляет хэндшейк (opcode 6). - Future sendHandshake() { - // TODO: заменить захардкоженные данные на реальные + Future sendHandshake() async { + DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + + // Если платформа Linux или Windows, то ставим DESKTOP, если нет, то проверяем на Android или IOS; + String deviceType = (Platform.isLinux || Platform.isWindows) ? "DESKTOP" : (Platform.isAndroid) ? "ANDROID" : "IOS"; + String osVersion = ""; + String deviceName = "Unknown"; + String architecture = "arm64"; + + tz.initializeTimeZones(); + + final now = DateTime.now(); + String timezone = "Europe/Moscow"; + + tz.initializeTimeZones(); + final timeZoneName = await FlutterTimezone.getLocalTimezone(); + timezone = timeZoneName.identifier; + + // На каждой платформе свое инфо, поэтому делаем такую проверку + if (Platform.isLinux) { + LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo; + + osVersion = linuxInfo.name; + // Platform.version содержит в себе что-то такое + // 3.11.1 (stable) (Tue Feb 24 00:03:07 2026 -0800) on "linux_x64" + // Поэтому мы находим '_', прибавляем к его индексу 1 и берем символы до length - 1 + architecture = Platform.version.substring(Platform.version.indexOf('_') + 1, Platform.version.length - 1); + } else if (Platform.isIOS) { + IosDeviceInfo iosInfo = await deviceInfo.iosInfo; + + osVersion = iosInfo.systemVersion; + deviceName = iosInfo.utsname.machine; + } else if (Platform.isAndroid) { + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + + osVersion = "Android ${androidInfo.version.release}"; + deviceName = "${androidInfo.manufacturer} ${androidInfo.model}"; + architecture = androidInfo.supportedAbis.first; + } else if (Platform.isWindows) { + WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; + + osVersion = windowsInfo.productName; + architecture = Platform.version.substring(Platform.version.indexOf('_') + 1, Platform.version.length - 1); + } + + print(deviceType); final payload = { 'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000', 'clientSessionId': 42, 'deviceId': 'a1b2c3d4e5f6a7b8', 'userAgent': { - 'deviceType': "ANDROID", - 'locale': 'en', - 'deviceLocale': 'en_US', - 'osVersion': 'Ondroid 14', - 'deviceName': 'KometPhone', + 'deviceType': deviceType, + // Первые два символа из locale это и есть нужный нам аргумент + 'locale': "ru", + 'deviceLocale': Platform.localeName.substring(0, 2), + 'osVersion': osVersion, + 'deviceName': deviceName, 'appVersion': '26.8.1', 'screen': '1920x1080', - 'timezone': 'Europe/Moscow', + // 'screen': screenSize.width + 'x' + screenSize.height, + 'timezone': timezone, 'pushDeviceType': 'GCM', - 'arch': 'arm64', + 'arch': architecture, 'buildNumber': 6606, }, }; + print(payload); + print(Platform.version); return sendRequest(Opcode.sessionInit, payload); } @@ -142,7 +201,8 @@ class Api { _stateController.close(); } - // Внутрянка + // Внутрянка + void _setSessionState(SessionState state) { if (_sessionState == state) return; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index d7c95f9..a7c2d62 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -4,7 +4,7 @@ import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; import '../utils/logger.dart'; -/// ver(1) + cmd(2) + seq(1) + opcode(2) + packedLen(4) = 10 +/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10 const int headerSize = 10; const int _maxDecompressedSize = 1048576; // 1 MB @@ -12,18 +12,19 @@ const int _maxDecompressedSize = 1048576; // 1 MB abstract class CmdType { static const int request = 0; // запрос клиента static const int push = 1; // пуш от сервера - static const int ok = 0x100; // ответ: ок - static const int notFound = 0x200; // ответ: не найдено - static const int error = 0x300; // ответ: ошибка + + static const int ok = 1; // ответ: ок + static const int notFound = 2; // ответ: не найдено + static const int error = 3; // ответ: ошибка } -/// Бинарный пакет +/// Распакованный бинарный пакет /// /// Формат заголовка (10 байт): /// ``` -/// [0] ver — версия протокола (uint8) -/// [1..2] cmd — тип команды (uint16 BE) -/// [3] seq — порядковый номер 0..255 (uint8) +/// [0] ver — версия протокола (uint8) (по умолчанию 10) +/// [1] cmd — тип команды (uint8) (при отправке от клиента равно 0) +/// [2..3] seq — порядковый номер (uint16 BE) /// [4..5] opcode — код операции (uint16 BE) /// [6..9] packedLen — флаг сжатия [6] + длина payload [7..9] (uint32 BE) /// [10..] payload — данные в MsgPack, опционально сжатые LZ4 @@ -56,8 +57,8 @@ class Packet { Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { final header = ByteData(headerSize); header.setUint8(0, 10); - header.setUint16(1, CmdType.request, Endian.big); - header.setUint8(3, seq); + header.setUint8(1, CmdType.request); + header.setUint16(2, seq, Endian.big); header.setUint16(4, opcode, Endian.big); final payloadBytes = msgpack.serialize(payload); @@ -69,28 +70,38 @@ Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { /// Распаковка пакета от сервера Packet unpackPacket(Uint8List packet) { - final data = ByteData.view( + // Для удобства расшифровки пакета переводим в ByteData + ByteData packetData = ByteData.view( packet.buffer, packet.offsetInBytes, packet.lengthInBytes, ); - final apiVer = data.getUint8(0); - final cmd = data.getUint16(1, Endian.big); - final seq = data.getUint8(3); - final opcode = data.getUint16(4, Endian.big); - final packedLen = data.getUint32(6, Endian.big); + // Объяснение каждой переменной смотри в классе Packet + // API версия и cmd представляют из себя 8 битные числа + final apiVer = packetData.getUint8(0) & 0xFF; + final cmd = packetData.getUint8(1) & 0xFF; + + // Sequence и OPCode представляют из себя 16 битные числа + final seq = packetData.getUint16(2) & 0xFFFF; + final opcode = packetData.getUint16(4) & 0xFFFF; + + // После базовых переменных идет длина пакета, является 32 битным числом + final packedLen = packetData.getUint32(6); + + // Compression flag показывает, сжат ли payload final compFlag = packedLen >> 24; + + // Длина payload'а final payloadLength = packedLen & 0xFFFFFF; - var payloadBytes = Uint8List.sublistView( - packet, - headerSize, - headerSize + payloadLength, - ); - dynamic payload; + // Байты payload'а, могут быть сжаты LZ4 + var payloadBytes = packet.buffer.asUint8List(10, payloadLength); + dynamic payload; + print(compFlag); + // Если payload пустой, ничего не делаем (так может быть при получении пинга) if (payloadBytes.isNotEmpty) { if (compFlag != 0) { try { @@ -98,6 +109,7 @@ Packet unpackPacket(Uint8List packet) { payloadBytes, decompressedSize: _maxDecompressedSize, ); + } catch (_) { // dart_lz4 не умеет block-формат, фолбэк на ручной декомпрессор try { @@ -110,7 +122,7 @@ Packet unpackPacket(Uint8List packet) { try { payload = msgpack.deserialize(payloadBytes); - } catch (e) { + } catch (e) { logger.e("Ошибка десериализации MsgPack: $e", error: e); } } diff --git a/pubspec.lock b/pubspec.lock index 3e29bd2..bc155f7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -57,6 +57,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -65,6 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -83,6 +123,35 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: "978192f2f9ea6d019a4de4f0211d76a9af955ca24865828fa98ca4e20cf0cb3c" + url: "https://pub.dev" + source: hosted + version: "5.0.1" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -127,18 +196,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -163,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" sky_engine: dependency: transitive description: flutter @@ -212,10 +289,26 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + url: "https://pub.dev" + source: hosted + version: "0.11.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -232,6 +325,30 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" sdks: dart: ">=3.10.4 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index a704a79..252d74e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,9 @@ dependencies: dart_lz4: ^1.0.0 msgpack_dart: ^1.0.1 logger: ^2.6.2 + device_info_plus: ^12.3.0 + flutter_timezone: ^5.0.1 + timezone: ^0.11.0 dev_dependencies: flutter_test: From 0508fe4f7fd60bfa77b1e90e6813abb030739639 Mon Sep 17 00:00:00 2001 From: noxzion Date: Sun, 15 Mar 2026 21:13:43 +0500 Subject: [PATCH 008/323] =?UTF-8?q?=D0=9F=D0=BE=D0=BC=D0=B5=D0=BD=D1=8F?= =?UTF-8?q?=D0=BB=20=D1=88=D1=80=D0=B8=D1=84=D1=82=D1=8B=20=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D1=85=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=85,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/code_confirmation_screen.dart | 6 +- lib/frontend/screens/auth/login_screen.dart | 260 +++++++++++------- lib/main.dart | 2 +- 3 files changed, 168 insertions(+), 100 deletions(-) diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 076f4f3..39b828b 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../chats/chat_list_screen.dart'; @@ -87,11 +88,10 @@ class _CodeConfirmationScreenState extends State { const SizedBox(height: 16), Text( widget.phoneNumber, - style: const TextStyle( + style: GoogleFonts.inter( color: Colors.white, fontSize: 22, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', + fontWeight: FontWeight.w500, ), ), const SizedBox(height: 12), diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 298ebd3..700742c 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'code_confirmation_screen.dart'; @@ -13,7 +14,7 @@ class LoginScreen extends StatefulWidget { class _LoginScreenState extends State { final TextEditingController _phoneController = TextEditingController(); - String? _errorText; + bool _isPhoneValid = false; void _showTOS(BuildContext context) { showModalBottomSheet( @@ -38,12 +39,12 @@ class _LoginScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( + Text( 'Условия использования', - style: TextStyle( + style: GoogleFonts.inter( color: Colors.white, fontSize: 20, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, ), ), IconButton( @@ -166,25 +167,102 @@ class _LoginScreenState extends State { ); } - void _validateAndSubmit() { - setState(() { - final phone = _phoneController.text.replaceAll(RegExp(r'\D'), ''); - if (phone.isEmpty) { - _errorText = 'Пожалуйста, введите номер телефона'; - } else if (phone.length < 10) { - _errorText = 'Номер телефона слишком короткий'; - } else { - _errorText = null; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CodeConfirmationScreen( - phoneNumber: '+7 ${_phoneController.text}', + void _showPhoneConfirmationDialog(String formattedPhone) { + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '', + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, anim1, anim2) => const SizedBox.shrink(), + transitionBuilder: (context, anim1, anim2, child) { + final curve = Curves.easeOutQuart.transform(anim1.value); + return Opacity( + opacity: anim1.value, + child: Transform.translate( + offset: Offset(0, 20 * (1 - curve)), + child: Transform.scale( + scale: 0.8 + (0.2 * curve), + child: AlertDialog( + backgroundColor: const Color(0xFF1E1E2A), + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + actionsPadding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Это правильный номер?', + style: GoogleFonts.inter( + color: Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Text( + '+7 $formattedPhone', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text( + 'Изменить', + style: TextStyle( + color: Color(0xFFAFAFFF), + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CodeConfirmationScreen( + phoneNumber: '+7 $formattedPhone', + ), + ), + ); + }, + child: const Text( + 'Готово', + style: TextStyle( + color: Color(0xFFAFAFFF), + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), ), ), ); - } - }); + }, + ); + } + + void _validateAndSubmit() { + _showPhoneConfirmationDialog(_phoneController.text); } @override @@ -242,9 +320,9 @@ class _LoginScreenState extends State { color: Colors.white, ), const SizedBox(height: 16), - const Text( + Text( 'Войдите в Komet', - style: TextStyle( + style: GoogleFonts.inter( color: Colors.white, fontSize: 20, fontWeight: FontWeight.w500, @@ -279,58 +357,55 @@ class _LoginScreenState extends State { ), ), const SizedBox(height: 12), - _buildInputField( - label: 'Номер телефона', - isError: _errorText != null, - errorText: _errorText, - content: Row( - children: [ - const Text( - '+7', - style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), - ), - const SizedBox(width: 12), - Container( - width: 1, - height: 24, - color: Colors.white24, - ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _phoneController, - keyboardType: TextInputType.phone, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - _PhoneInputFormatter(), - ], - style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), - decoration: const InputDecoration( - hintText: '(000) 000-00-00', - hintStyle: TextStyle(color: Colors.white38, fontSize: 15, fontWeight: FontWeight.w400), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - onChanged: (value) { - if (_errorText != null) { - setState(() { - _errorText = null; - }); - } - }, + _buildInputField( + label: 'Номер телефона', + content: Row( + children: [ + const Text( + '+7', + style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), ), - ), - ], + const SizedBox(width: 12), + Container( + width: 1, + height: 24, + color: Colors.white24, + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + _PhoneInputFormatter(), + ], + style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), + decoration: const InputDecoration( + hintText: '(000) 000-00-00', + hintStyle: TextStyle(color: Colors.white38, fontSize: 15, fontWeight: FontWeight.w400), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (value) { + final digits = value.replaceAll(RegExp(r'\D'), ''); + setState(() { + _isPhoneValid = digits.length == 10; + }); + }, + ), + ), + ], + ), ), - ), const SizedBox(height: 24), TextButton( onPressed: () {}, - child: const Text( + child: Text( 'Другие способы входа', - style: TextStyle( - color: Color(0xFFAFAFFF), + style: GoogleFonts.inter( + color: const Color(0xFFAFAFFF), fontSize: 14, fontWeight: FontWeight.w400, height: 1.4, @@ -346,20 +421,29 @@ class _LoginScreenState extends State { padding: const EdgeInsets.only(bottom: 24.0), child: RichText( text: TextSpan( - style: const TextStyle( + style: GoogleFonts.inter( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w500, height: 1.4, - fontFamily: 'Outfit', ), children: [ - const TextSpan(text: 'Продолжая, вы соглашаетесь с\n'), + TextSpan( + text: 'Продолжая, вы соглашаетесь с \n', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), TextSpan( text: 'пользовательскими соглашениями', - style: const TextStyle( - color: Color(0xFFAFAFFF), - fontWeight: FontWeight.w500, + style: GoogleFonts.inter( + color: const Color(0xFFAFAFFF), + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, ), recognizer: TapGestureRecognizer() ..onTap = () => _showTOS(context), @@ -372,12 +456,12 @@ class _LoginScreenState extends State { Padding( padding: const EdgeInsets.only(bottom: 16.0), child: FloatingActionButton( - onPressed: _validateAndSubmit, - backgroundColor: const Color(0xffc1c4ff), + onPressed: _isPhoneValid ? _validateAndSubmit : null, + backgroundColor: _isPhoneValid ? const Color(0xffc1c4ff) : Colors.white10, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50), ), - child: const Icon(Icons.arrow_forward, color: Colors.black), + child: Icon(Icons.arrow_forward, color: _isPhoneValid ? Colors.black : Colors.white24), ), ), ], @@ -400,8 +484,6 @@ class _LoginScreenState extends State { Widget _buildInputField({ required String label, required Widget content, - bool isError = false, - String? errorText, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -415,7 +497,7 @@ class _LoginScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( border: Border.all( - color: isError ? Colors.redAccent : const Color(0xFFBEC2FF), + color: const Color(0xFFBEC2FF), width: 1.5, ), borderRadius: BorderRadius.circular(50), @@ -430,8 +512,8 @@ class _LoginScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( label, - style: TextStyle( - color: isError ? Colors.redAccent : const Color(0xFFBEC2FF), + style: const TextStyle( + color: Color(0xFFBEC2FF), fontSize: 14, fontWeight: FontWeight.w400, ), @@ -440,20 +522,6 @@ class _LoginScreenState extends State { ), ], ), - if (isError && errorText != null) ...[ - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 16), - child: Text( - errorText, - style: const TextStyle( - color: Colors.redAccent, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - ), - ], ], ); } diff --git a/lib/main.dart b/lib/main.dart index 8c52477..f273ad7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,7 @@ class MyApp extends StatelessWidget { theme: ThemeData( useMaterial3: true, brightness: Brightness.dark, - textTheme: GoogleFonts.outfitTextTheme( + textTheme: GoogleFonts.interTextTheme( ThemeData.dark().textTheme, ), ), From bc4b4ba49493e7b4d8854d59a155c3d80b0737b6 Mon Sep 17 00:00:00 2001 From: Klockky Date: Sun, 15 Mar 2026 19:36:51 +0300 Subject: [PATCH 009/323] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 43 ++ lib/core/config/countries.dart | 790 +++++++++++++------------ macos/Flutter/Flutter-Debug.xcconfig | 1 + macos/Flutter/Flutter-Release.xcconfig | 1 + macos/Podfile | 42 ++ pubspec.lock | 16 +- 8 files changed, 500 insertions(+), 395 deletions(-) create mode 100644 ios/Podfile create mode 100644 macos/Podfile diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart index 860f069..1c65b81 100644 --- a/lib/core/config/countries.dart +++ b/lib/core/config/countries.dart @@ -4,11 +4,21 @@ class CountryName { final String code; // ISO 3166-1 alpha-2, например "RU" final String en; final String ru; + final String phoneCode; // Код страны для звонков, например "+7" + final int phoneDigits; // Количество цифр номера после кода страны + final String phoneMask; // Маска абонентского номера, например "(###) ###-##-##" + final List phoneGroupSizes; // Размеры групп цифр, например [3, 3, 2, 2] + final List phoneGroupSeparators; // Разделители вокруг групп, например ["(", ") ", "-", "-", ""] const CountryName({ required this.code, required this.en, required this.ru, + required this.phoneCode, + required this.phoneDigits, + required this.phoneMask, + required this.phoneGroupSizes, + required this.phoneGroupSeparators, }); } @@ -56,7 +66,13 @@ List _buildCountries() { final en = item['name'] as String; final ru = ruByCode[code] ?? en; - return CountryName(code: code, en: en, ru: ru); + final phoneCode = item['phoneCode'] as String; + final phoneDigits = item['phoneDigits'] as int; + final phoneMask = item['phoneMask'] as String; + final phoneGroupSizes = (item['phoneGroupSizes'] as List).cast(); + final phoneGroupSeparators = (item['phoneGroupSeparators'] as List).cast(); + + return CountryName(code: code, en: en, ru: ru, phoneCode: phoneCode, phoneDigits: phoneDigits, phoneMask: phoneMask, phoneGroupSizes: phoneGroupSizes, phoneGroupSeparators: phoneGroupSeparators); }).toList(); countries.sort((a, b) => a.en.compareTo(b.en)); @@ -64,393 +80,393 @@ List _buildCountries() { } const String _countriesEnJson = '''[ -{"id":4,"alpha2":"af","alpha3":"afg","name":"Afghanistan"}, -{"id":8,"alpha2":"al","alpha3":"alb","name":"Albania"}, -{"id":12,"alpha2":"dz","alpha3":"dza","name":"Algeria"}, -{"id":20,"alpha2":"ad","alpha3":"and","name":"Andorra"}, -{"id":24,"alpha2":"ao","alpha3":"ago","name":"Angola"}, -{"id":28,"alpha2":"ag","alpha3":"atg","name":"Antigua and Barbuda"}, -{"id":32,"alpha2":"ar","alpha3":"arg","name":"Argentina"}, -{"id":51,"alpha2":"am","alpha3":"arm","name":"Armenia"}, -{"id":36,"alpha2":"au","alpha3":"aus","name":"Australia"}, -{"id":40,"alpha2":"at","alpha3":"aut","name":"Austria"}, -{"id":31,"alpha2":"az","alpha3":"aze","name":"Azerbaijan"}, -{"id":44,"alpha2":"bs","alpha3":"bhs","name":"Bahamas"}, -{"id":48,"alpha2":"bh","alpha3":"bhr","name":"Bahrain"}, -{"id":50,"alpha2":"bd","alpha3":"bgd","name":"Bangladesh"}, -{"id":52,"alpha2":"bb","alpha3":"brb","name":"Barbados"}, -{"id":112,"alpha2":"by","alpha3":"blr","name":"Belarus"}, -{"id":56,"alpha2":"be","alpha3":"bel","name":"Belgium"}, -{"id":84,"alpha2":"bz","alpha3":"blz","name":"Belize"}, -{"id":204,"alpha2":"bj","alpha3":"ben","name":"Benin"}, -{"id":64,"alpha2":"bt","alpha3":"btn","name":"Bhutan"}, -{"id":68,"alpha2":"bo","alpha3":"bol","name":"Bolivia, Plurinational State of"}, -{"id":70,"alpha2":"ba","alpha3":"bih","name":"Bosnia and Herzegovina"}, -{"id":72,"alpha2":"bw","alpha3":"bwa","name":"Botswana"}, -{"id":76,"alpha2":"br","alpha3":"bra","name":"Brazil"}, -{"id":96,"alpha2":"bn","alpha3":"brn","name":"Brunei Darussalam"}, -{"id":100,"alpha2":"bg","alpha3":"bgr","name":"Bulgaria"}, -{"id":854,"alpha2":"bf","alpha3":"bfa","name":"Burkina Faso"}, -{"id":108,"alpha2":"bi","alpha3":"bdi","name":"Burundi"}, -{"id":132,"alpha2":"cv","alpha3":"cpv","name":"Cabo Verde"}, -{"id":116,"alpha2":"kh","alpha3":"khm","name":"Cambodia"}, -{"id":120,"alpha2":"cm","alpha3":"cmr","name":"Cameroon"}, -{"id":124,"alpha2":"ca","alpha3":"can","name":"Canada"}, -{"id":140,"alpha2":"cf","alpha3":"caf","name":"Central African Republic"}, -{"id":148,"alpha2":"td","alpha3":"tcd","name":"Chad"}, -{"id":152,"alpha2":"cl","alpha3":"chl","name":"Chile"}, -{"id":156,"alpha2":"cn","alpha3":"chn","name":"China"}, -{"id":170,"alpha2":"co","alpha3":"col","name":"Colombia"}, -{"id":174,"alpha2":"km","alpha3":"com","name":"Comoros"}, -{"id":178,"alpha2":"cg","alpha3":"cog","name":"Congo"}, -{"id":180,"alpha2":"cd","alpha3":"cod","name":"Congo, Democratic Republic of the"}, -{"id":188,"alpha2":"cr","alpha3":"cri","name":"Costa Rica"}, -{"id":384,"alpha2":"ci","alpha3":"civ","name":"Côte d'Ivoire"}, -{"id":191,"alpha2":"hr","alpha3":"hrv","name":"Croatia"}, -{"id":192,"alpha2":"cu","alpha3":"cub","name":"Cuba"}, -{"id":196,"alpha2":"cy","alpha3":"cyp","name":"Cyprus"}, -{"id":203,"alpha2":"cz","alpha3":"cze","name":"Czechia"}, -{"id":208,"alpha2":"dk","alpha3":"dnk","name":"Denmark"}, -{"id":262,"alpha2":"dj","alpha3":"dji","name":"Djibouti"}, -{"id":212,"alpha2":"dm","alpha3":"dma","name":"Dominica"}, -{"id":214,"alpha2":"do","alpha3":"dom","name":"Dominican Republic"}, -{"id":218,"alpha2":"ec","alpha3":"ecu","name":"Ecuador"}, -{"id":818,"alpha2":"eg","alpha3":"egy","name":"Egypt"}, -{"id":222,"alpha2":"sv","alpha3":"slv","name":"El Salvador"}, -{"id":226,"alpha2":"gq","alpha3":"gnq","name":"Equatorial Guinea"}, -{"id":232,"alpha2":"er","alpha3":"eri","name":"Eritrea"}, -{"id":233,"alpha2":"ee","alpha3":"est","name":"Estonia"}, -{"id":748,"alpha2":"sz","alpha3":"swz","name":"Eswatini"}, -{"id":231,"alpha2":"et","alpha3":"eth","name":"Ethiopia"}, -{"id":242,"alpha2":"fj","alpha3":"fji","name":"Fiji"}, -{"id":246,"alpha2":"fi","alpha3":"fin","name":"Finland"}, -{"id":250,"alpha2":"fr","alpha3":"fra","name":"France"}, -{"id":266,"alpha2":"ga","alpha3":"gab","name":"Gabon"}, -{"id":270,"alpha2":"gm","alpha3":"gmb","name":"Gambia"}, -{"id":268,"alpha2":"ge","alpha3":"geo","name":"Georgia"}, -{"id":276,"alpha2":"de","alpha3":"deu","name":"Germany"}, -{"id":288,"alpha2":"gh","alpha3":"gha","name":"Ghana"}, -{"id":300,"alpha2":"gr","alpha3":"grc","name":"Greece"}, -{"id":308,"alpha2":"gd","alpha3":"grd","name":"Grenada"}, -{"id":320,"alpha2":"gt","alpha3":"gtm","name":"Guatemala"}, -{"id":324,"alpha2":"gn","alpha3":"gin","name":"Guinea"}, -{"id":624,"alpha2":"gw","alpha3":"gnb","name":"Guinea-Bissau"}, -{"id":328,"alpha2":"gy","alpha3":"guy","name":"Guyana"}, -{"id":332,"alpha2":"ht","alpha3":"hti","name":"Haiti"}, -{"id":340,"alpha2":"hn","alpha3":"hnd","name":"Honduras"}, -{"id":348,"alpha2":"hu","alpha3":"hun","name":"Hungary"}, -{"id":352,"alpha2":"is","alpha3":"isl","name":"Iceland"}, -{"id":356,"alpha2":"in","alpha3":"ind","name":"India"}, -{"id":360,"alpha2":"id","alpha3":"idn","name":"Indonesia"}, -{"id":364,"alpha2":"ir","alpha3":"irn","name":"Iran, Islamic Republic of"}, -{"id":368,"alpha2":"iq","alpha3":"irq","name":"Iraq"}, -{"id":372,"alpha2":"ie","alpha3":"irl","name":"Ireland"}, -{"id":376,"alpha2":"il","alpha3":"isr","name":"Israel"}, -{"id":380,"alpha2":"it","alpha3":"ita","name":"Italy"}, -{"id":388,"alpha2":"jm","alpha3":"jam","name":"Jamaica"}, -{"id":392,"alpha2":"jp","alpha3":"jpn","name":"Japan"}, -{"id":400,"alpha2":"jo","alpha3":"jor","name":"Jordan"}, -{"id":398,"alpha2":"kz","alpha3":"kaz","name":"Kazakhstan"}, -{"id":404,"alpha2":"ke","alpha3":"ken","name":"Kenya"}, -{"id":296,"alpha2":"ki","alpha3":"kir","name":"Kiribati"}, -{"id":408,"alpha2":"kp","alpha3":"prk","name":"Korea, Democratic People's Republic of"}, -{"id":410,"alpha2":"kr","alpha3":"kor","name":"Korea, Republic of"}, -{"id":414,"alpha2":"kw","alpha3":"kwt","name":"Kuwait"}, -{"id":417,"alpha2":"kg","alpha3":"kgz","name":"Kyrgyzstan"}, -{"id":418,"alpha2":"la","alpha3":"lao","name":"Lao People's Democratic Republic"}, -{"id":428,"alpha2":"lv","alpha3":"lva","name":"Latvia"}, -{"id":422,"alpha2":"lb","alpha3":"lbn","name":"Lebanon"}, -{"id":426,"alpha2":"ls","alpha3":"lso","name":"Lesotho"}, -{"id":430,"alpha2":"lr","alpha3":"lbr","name":"Liberia"}, -{"id":434,"alpha2":"ly","alpha3":"lby","name":"Libya"}, -{"id":438,"alpha2":"li","alpha3":"lie","name":"Liechtenstein"}, -{"id":440,"alpha2":"lt","alpha3":"ltu","name":"Lithuania"}, -{"id":442,"alpha2":"lu","alpha3":"lux","name":"Luxembourg"}, -{"id":450,"alpha2":"mg","alpha3":"mdg","name":"Madagascar"}, -{"id":454,"alpha2":"mw","alpha3":"mwi","name":"Malawi"}, -{"id":458,"alpha2":"my","alpha3":"mys","name":"Malaysia"}, -{"id":462,"alpha2":"mv","alpha3":"mdv","name":"Maldives"}, -{"id":466,"alpha2":"ml","alpha3":"mli","name":"Mali"}, -{"id":470,"alpha2":"mt","alpha3":"mlt","name":"Malta"}, -{"id":584,"alpha2":"mh","alpha3":"mhl","name":"Marshall Islands"}, -{"id":478,"alpha2":"mr","alpha3":"mrt","name":"Mauritania"}, -{"id":480,"alpha2":"mu","alpha3":"mus","name":"Mauritius"}, -{"id":484,"alpha2":"mx","alpha3":"mex","name":"Mexico"}, -{"id":583,"alpha2":"fm","alpha3":"fsm","name":"Micronesia, Federated States of"}, -{"id":498,"alpha2":"md","alpha3":"mda","name":"Moldova, Republic of"}, -{"id":492,"alpha2":"mc","alpha3":"mco","name":"Monaco"}, -{"id":496,"alpha2":"mn","alpha3":"mng","name":"Mongolia"}, -{"id":499,"alpha2":"me","alpha3":"mne","name":"Montenegro"}, -{"id":504,"alpha2":"ma","alpha3":"mar","name":"Morocco"}, -{"id":508,"alpha2":"mz","alpha3":"moz","name":"Mozambique"}, -{"id":104,"alpha2":"mm","alpha3":"mmr","name":"Myanmar"}, -{"id":516,"alpha2":"na","alpha3":"nam","name":"Namibia"}, -{"id":520,"alpha2":"nr","alpha3":"nru","name":"Nauru"}, -{"id":524,"alpha2":"np","alpha3":"npl","name":"Nepal"}, -{"id":528,"alpha2":"nl","alpha3":"nld","name":"Netherlands"}, -{"id":554,"alpha2":"nz","alpha3":"nzl","name":"New Zealand"}, -{"id":558,"alpha2":"ni","alpha3":"nic","name":"Nicaragua"}, -{"id":562,"alpha2":"ne","alpha3":"ner","name":"Niger"}, -{"id":566,"alpha2":"ng","alpha3":"nga","name":"Nigeria"}, -{"id":807,"alpha2":"mk","alpha3":"mkd","name":"North Macedonia"}, -{"id":578,"alpha2":"no","alpha3":"nor","name":"Norway"}, -{"id":512,"alpha2":"om","alpha3":"omn","name":"Oman"}, -{"id":586,"alpha2":"pk","alpha3":"pak","name":"Pakistan"}, -{"id":585,"alpha2":"pw","alpha3":"plw","name":"Palau"}, -{"id":591,"alpha2":"pa","alpha3":"pan","name":"Panama"}, -{"id":598,"alpha2":"pg","alpha3":"png","name":"Papua New Guinea"}, -{"id":600,"alpha2":"py","alpha3":"pry","name":"Paraguay"}, -{"id":604,"alpha2":"pe","alpha3":"per","name":"Peru"}, -{"id":608,"alpha2":"ph","alpha3":"phl","name":"Philippines"}, -{"id":616,"alpha2":"pl","alpha3":"pol","name":"Poland"}, -{"id":620,"alpha2":"pt","alpha3":"prt","name":"Portugal"}, -{"id":634,"alpha2":"qa","alpha3":"qat","name":"Qatar"}, -{"id":642,"alpha2":"ro","alpha3":"rou","name":"Romania"}, -{"id":643,"alpha2":"ru","alpha3":"rus","name":"Russian Federation"}, -{"id":646,"alpha2":"rw","alpha3":"rwa","name":"Rwanda"}, -{"id":659,"alpha2":"kn","alpha3":"kna","name":"Saint Kitts and Nevis"}, -{"id":662,"alpha2":"lc","alpha3":"lca","name":"Saint Lucia"}, -{"id":670,"alpha2":"vc","alpha3":"vct","name":"Saint Vincent and the Grenadines"}, -{"id":882,"alpha2":"ws","alpha3":"wsm","name":"Samoa"}, -{"id":674,"alpha2":"sm","alpha3":"smr","name":"San Marino"}, -{"id":678,"alpha2":"st","alpha3":"stp","name":"Sao Tome and Principe"}, -{"id":682,"alpha2":"sa","alpha3":"sau","name":"Saudi Arabia"}, -{"id":686,"alpha2":"sn","alpha3":"sen","name":"Senegal"}, -{"id":688,"alpha2":"rs","alpha3":"srb","name":"Serbia"}, -{"id":690,"alpha2":"sc","alpha3":"syc","name":"Seychelles"}, -{"id":694,"alpha2":"sl","alpha3":"sle","name":"Sierra Leone"}, -{"id":702,"alpha2":"sg","alpha3":"sgp","name":"Singapore"}, -{"id":703,"alpha2":"sk","alpha3":"svk","name":"Slovakia"}, -{"id":705,"alpha2":"si","alpha3":"svn","name":"Slovenia"}, -{"id":90,"alpha2":"sb","alpha3":"slb","name":"Solomon Islands"}, -{"id":706,"alpha2":"so","alpha3":"som","name":"Somalia"}, -{"id":710,"alpha2":"za","alpha3":"zaf","name":"South Africa"}, -{"id":728,"alpha2":"ss","alpha3":"ssd","name":"South Sudan"}, -{"id":724,"alpha2":"es","alpha3":"esp","name":"Spain"}, -{"id":144,"alpha2":"lk","alpha3":"lka","name":"Sri Lanka"}, -{"id":729,"alpha2":"sd","alpha3":"sdn","name":"Sudan"}, -{"id":740,"alpha2":"sr","alpha3":"sur","name":"Suriname"}, -{"id":752,"alpha2":"se","alpha3":"swe","name":"Sweden"}, -{"id":756,"alpha2":"ch","alpha3":"che","name":"Switzerland"}, -{"id":760,"alpha2":"sy","alpha3":"syr","name":"Syrian Arab Republic"}, -{"id":762,"alpha2":"tj","alpha3":"tjk","name":"Tajikistan"}, -{"id":834,"alpha2":"tz","alpha3":"tza","name":"Tanzania, United Republic of"}, -{"id":764,"alpha2":"th","alpha3":"tha","name":"Thailand"}, -{"id":626,"alpha2":"tl","alpha3":"tls","name":"Timor-Leste"}, -{"id":768,"alpha2":"tg","alpha3":"tgo","name":"Togo"}, -{"id":776,"alpha2":"to","alpha3":"ton","name":"Tonga"}, -{"id":780,"alpha2":"tt","alpha3":"tto","name":"Trinidad and Tobago"}, -{"id":788,"alpha2":"tn","alpha3":"tun","name":"Tunisia"}, -{"id":792,"alpha2":"tr","alpha3":"tur","name":"Türkiye"}, -{"id":795,"alpha2":"tm","alpha3":"tkm","name":"Turkmenistan"}, -{"id":798,"alpha2":"tv","alpha3":"tuv","name":"Tuvalu"}, -{"id":800,"alpha2":"ug","alpha3":"uga","name":"Uganda"}, -{"id":804,"alpha2":"ua","alpha3":"ukr","name":"Ukraine"}, -{"id":784,"alpha2":"ae","alpha3":"are","name":"United Arab Emirates"}, -{"id":826,"alpha2":"gb","alpha3":"gbr","name":"United Kingdom of Great Britain and Northern Ireland"}, -{"id":840,"alpha2":"us","alpha3":"usa","name":"United States of America"}, -{"id":858,"alpha2":"uy","alpha3":"ury","name":"Uruguay"}, -{"id":860,"alpha2":"uz","alpha3":"uzb","name":"Uzbekistan"}, -{"id":548,"alpha2":"vu","alpha3":"vut","name":"Vanuatu"}, -{"id":862,"alpha2":"ve","alpha3":"ven","name":"Venezuela, Bolivarian Republic of"}, -{"id":704,"alpha2":"vn","alpha3":"vnm","name":"Viet Nam"}, -{"id":887,"alpha2":"ye","alpha3":"yem","name":"Yemen"}, -{"id":894,"alpha2":"zm","alpha3":"zmb","name":"Zambia"}, -{"id":716,"alpha2":"zw","alpha3":"zwe","name":"Zimbabwe"} +{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Afghanistan", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Albania", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Algeria", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Andorra", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]} +{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Angola", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Antigua and Barbuda", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Argentina", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Armenia", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Australia", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Austria", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Azerbaijan", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Bahamas", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Bahrain", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Bangladesh", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]} +{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Barbados", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Belarus", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]} +{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Belgium", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Belize", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} +{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Benin", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Bhutan", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Bolivia, Plurinational State of", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]} +{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Bosnia and Herzegovina", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]} +{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Botswana", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Brazil", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Brunei Darussalam", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Bulgaria", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Burkina Faso", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Burundi", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Cabo Verde", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Cambodia", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Cameroon", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Canada", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "Central African Republic", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Chad", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Chile", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "China", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Colombia", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Comoros", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Congo", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "Congo, Democratic Republic of the", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Costa Rica", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Côte d'Ivoire", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} +{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Croatia", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Cuba", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]} +{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Cyprus", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Czechia", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Denmark", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Djibouti", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Dominica", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Dominican Republic", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Ecuador", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Egypt", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "El Salvador", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Equatorial Guinea", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Eritrea", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Estonia", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Eswatini", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Ethiopia", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Fiji", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Finland", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "France", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} +{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Gabon", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Gambia", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Georgia", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Germany", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]} +{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Ghana", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Greece", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Grenada", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Guatemala", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Guinea", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Guinea-Bissau", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Guyana", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} +{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Haiti", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Honduras", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]} +{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Hungary", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Iceland", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "India", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]} +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Iran, Islamic Republic of", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Iraq", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ireland", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Israel", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]} +{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Italy", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Jamaica", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Japan", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]} +{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Jordan", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Kazakhstan", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]} +{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Kenya", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Kiribati", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "Korea, Democratic People's Republic of", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Korea, Republic of", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]} +{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Kuwait", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Kyrgyzstan", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Lao People's Democratic Republic", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Latvia", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Lebanon", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Lesotho", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Liberia", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Libya", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Liechtenstein", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Lithuania", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]} +{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Luxembourg", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Madagascar", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Malawi", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Malaysia", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]} +{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Maldives", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Mali", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Malta", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Marshall Islands", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Mauritania", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Mauritius", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Mexico", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Micronesia, Federated States of", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Moldova, Republic of", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Monaco", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Mongolia", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Montenegro", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Morocco", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]} +{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Mozambique", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Myanmar", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Namibia", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Nauru", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Nepal", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} +{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Netherlands", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} +{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "New Zealand", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} +{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Nicaragua", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Niger", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Nigeria", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "North Macedonia", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Norway", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Oman", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Pakistan", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]} +{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Palau", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Panama", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Papua New Guinea", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Paraguay", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Peru", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Philippines", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Poland", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Portugal", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Qatar", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Romania", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Russian Federation", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]} +{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Rwanda", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Saint Kitts and Nevis", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Saint Lucia", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Saint Vincent and the Grenadines", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Samoa", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]} +{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "San Marino", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} +{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Sao Tome and Principe", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Saudi Arabia", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Senegal", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Serbia", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Seychelles", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Sierra Leone", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Singapore", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Slovakia", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Slovenia", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Solomon Islands", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Somalia", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "South Africa", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "South Sudan", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Spain", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Sri Lanka", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Sudan", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Suriname", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} +{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Sweden", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]} +{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Switzerland", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Syrian Arab Republic", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Tajikistan", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Tanzania, United Republic of", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Thailand", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} +{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Timor-Leste", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Togo", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Tonga", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Trinidad and Tobago", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Tunisia", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Türkiye", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Turkmenistan", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Tuvalu", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]} +{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Uganda", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Ukraine", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "United Arab Emirates", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "United Kingdom of Great Britain and Northern Ireland", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "United States of America", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Uruguay", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Uzbekistan", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Vanuatu", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Venezuela, Bolivarian Republic of", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Viet Nam", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Yemen", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Zambia", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Zimbabwe", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} ]'''; const String _countriesRuJson = '''[ -{"id":36,"alpha2":"au","alpha3":"aus","name":"Австралия"}, -{"id":40,"alpha2":"at","alpha3":"aut","name":"Австрия"}, -{"id":31,"alpha2":"az","alpha3":"aze","name":"Азербайджан"}, -{"id":8,"alpha2":"al","alpha3":"alb","name":"Албания"}, -{"id":12,"alpha2":"dz","alpha3":"dza","name":"Алжир"}, -{"id":24,"alpha2":"ao","alpha3":"ago","name":"Ангола"}, -{"id":20,"alpha2":"ad","alpha3":"and","name":"Андорра"}, -{"id":28,"alpha2":"ag","alpha3":"atg","name":"Антигуа и Барбуда"}, -{"id":32,"alpha2":"ar","alpha3":"arg","name":"Аргентина"}, -{"id":51,"alpha2":"am","alpha3":"arm","name":"Армения"}, -{"id":4,"alpha2":"af","alpha3":"afg","name":"Афганистан"}, -{"id":44,"alpha2":"bs","alpha3":"bhs","name":"Багамские Острова"}, -{"id":50,"alpha2":"bd","alpha3":"bgd","name":"Бангладеш"}, -{"id":52,"alpha2":"bb","alpha3":"brb","name":"Барбадос"}, -{"id":48,"alpha2":"bh","alpha3":"bhr","name":"Бахрейн"}, -{"id":84,"alpha2":"bz","alpha3":"blz","name":"Белиз"}, -{"id":112,"alpha2":"by","alpha3":"blr","name":"Беларусь"}, -{"id":56,"alpha2":"be","alpha3":"bel","name":"Бельгия"}, -{"id":204,"alpha2":"bj","alpha3":"ben","name":"Бенин"}, -{"id":100,"alpha2":"bg","alpha3":"bgr","name":"Болгария"}, -{"id":68,"alpha2":"bo","alpha3":"bol","name":"Боливия"}, -{"id":70,"alpha2":"ba","alpha3":"bih","name":"Босния и Герцеговина"}, -{"id":72,"alpha2":"bw","alpha3":"bwa","name":"Ботсвана"}, -{"id":76,"alpha2":"br","alpha3":"bra","name":"Бразилия"}, -{"id":96,"alpha2":"bn","alpha3":"brn","name":"Бруней"}, -{"id":854,"alpha2":"bf","alpha3":"bfa","name":"Буркина-Фасо"}, -{"id":108,"alpha2":"bi","alpha3":"bdi","name":"Бурунди"}, -{"id":64,"alpha2":"bt","alpha3":"btn","name":"Бутан"}, -{"id":548,"alpha2":"vu","alpha3":"vut","name":"Вануату"}, -{"id":826,"alpha2":"gb","alpha3":"gbr","name":"Великобритания"}, -{"id":348,"alpha2":"hu","alpha3":"hun","name":"Венгрия"}, -{"id":862,"alpha2":"ve","alpha3":"ven","name":"Венесуэла"}, -{"id":626,"alpha2":"tl","alpha3":"tls","name":"Восточный Тимор"}, -{"id":704,"alpha2":"vn","alpha3":"vnm","name":"Вьетнам"}, -{"id":266,"alpha2":"ga","alpha3":"gab","name":"Габон"}, -{"id":332,"alpha2":"ht","alpha3":"hti","name":"Гаити"}, -{"id":328,"alpha2":"gy","alpha3":"guy","name":"Гайана"}, -{"id":270,"alpha2":"gm","alpha3":"gmb","name":"Гамбия"}, -{"id":288,"alpha2":"gh","alpha3":"gha","name":"Гана"}, -{"id":320,"alpha2":"gt","alpha3":"gtm","name":"Гватемала"}, -{"id":324,"alpha2":"gn","alpha3":"gin","name":"Гвинея"}, -{"id":624,"alpha2":"gw","alpha3":"gnb","name":"Гвинея-Бисау"}, -{"id":276,"alpha2":"de","alpha3":"deu","name":"Германия"}, -{"id":340,"alpha2":"hn","alpha3":"hnd","name":"Гондурас"}, -{"id":308,"alpha2":"gd","alpha3":"grd","name":"Гренада"}, -{"id":300,"alpha2":"gr","alpha3":"grc","name":"Греция"}, -{"id":268,"alpha2":"ge","alpha3":"geo","name":"Грузия"}, -{"id":208,"alpha2":"dk","alpha3":"dnk","name":"Дания"}, -{"id":262,"alpha2":"dj","alpha3":"dji","name":"Джибути"}, -{"id":212,"alpha2":"dm","alpha3":"dma","name":"Доминика"}, -{"id":214,"alpha2":"do","alpha3":"dom","name":"Доминиканская Республика"}, -{"id":180,"alpha2":"cd","alpha3":"cod","name":"ДР Конго"}, -{"id":818,"alpha2":"eg","alpha3":"egy","name":"Египет"}, -{"id":894,"alpha2":"zm","alpha3":"zmb","name":"Замбия"}, -{"id":716,"alpha2":"zw","alpha3":"zwe","name":"Зимбабве"}, -{"id":376,"alpha2":"il","alpha3":"isr","name":"Израиль"}, -{"id":356,"alpha2":"in","alpha3":"ind","name":"Индия"}, -{"id":360,"alpha2":"id","alpha3":"idn","name":"Индонезия"}, -{"id":400,"alpha2":"jo","alpha3":"jor","name":"Иордания"}, -{"id":368,"alpha2":"iq","alpha3":"irq","name":"Ирак"}, -{"id":364,"alpha2":"ir","alpha3":"irn","name":"Иран"}, -{"id":372,"alpha2":"ie","alpha3":"irl","name":"Ирландия"}, -{"id":352,"alpha2":"is","alpha3":"isl","name":"Исландия"}, -{"id":724,"alpha2":"es","alpha3":"esp","name":"Испания"}, -{"id":380,"alpha2":"it","alpha3":"ita","name":"Италия"}, -{"id":887,"alpha2":"ye","alpha3":"yem","name":"Йемен"}, -{"id":132,"alpha2":"cv","alpha3":"cpv","name":"Кабо-Верде"}, -{"id":398,"alpha2":"kz","alpha3":"kaz","name":"Казахстан"}, -{"id":116,"alpha2":"kh","alpha3":"khm","name":"Камбоджа"}, -{"id":120,"alpha2":"cm","alpha3":"cmr","name":"Камерун"}, -{"id":124,"alpha2":"ca","alpha3":"can","name":"Канада"}, -{"id":634,"alpha2":"qa","alpha3":"qat","name":"Катар"}, -{"id":404,"alpha2":"ke","alpha3":"ken","name":"Кения"}, -{"id":196,"alpha2":"cy","alpha3":"cyp","name":"Кипр"}, -{"id":417,"alpha2":"kg","alpha3":"kgz","name":"Кыргызстан"}, -{"id":296,"alpha2":"ki","alpha3":"kir","name":"Кирибати"}, -{"id":408,"alpha2":"kp","alpha3":"prk","name":"КНДР (Корейская Народно-Демократическая Республика)"}, -{"id":156,"alpha2":"cn","alpha3":"chn","name":"Китай (Китайская Народная Республика)"}, -{"id":170,"alpha2":"co","alpha3":"col","name":"Колумбия"}, -{"id":174,"alpha2":"km","alpha3":"com","name":"Коморы"}, -{"id":188,"alpha2":"cr","alpha3":"cri","name":"Коста-Рика"}, -{"id":384,"alpha2":"ci","alpha3":"civ","name":"Кот-д’Ивуар"}, -{"id":192,"alpha2":"cu","alpha3":"cub","name":"Куба"}, -{"id":414,"alpha2":"kw","alpha3":"kwt","name":"Кувейт"}, -{"id":418,"alpha2":"la","alpha3":"lao","name":"Лаос"}, -{"id":428,"alpha2":"lv","alpha3":"lva","name":"Латвия"}, -{"id":426,"alpha2":"ls","alpha3":"lso","name":"Лесото"}, -{"id":430,"alpha2":"lr","alpha3":"lbr","name":"Либерия"}, -{"id":422,"alpha2":"lb","alpha3":"lbn","name":"Ливан"}, -{"id":434,"alpha2":"ly","alpha3":"lby","name":"Ливия"}, -{"id":440,"alpha2":"lt","alpha3":"ltu","name":"Литва"}, -{"id":438,"alpha2":"li","alpha3":"lie","name":"Лихтенштейн"}, -{"id":442,"alpha2":"lu","alpha3":"lux","name":"Люксембург"}, -{"id":480,"alpha2":"mu","alpha3":"mus","name":"Маврикий"}, -{"id":478,"alpha2":"mr","alpha3":"mrt","name":"Мавритания"}, -{"id":450,"alpha2":"mg","alpha3":"mdg","name":"Мадагаскар"}, -{"id":807,"alpha2":"mk","alpha3":"mkd","name":"Северная Македония"}, -{"id":454,"alpha2":"mw","alpha3":"mwi","name":"Малави"}, -{"id":458,"alpha2":"my","alpha3":"mys","name":"Малайзия"}, -{"id":466,"alpha2":"ml","alpha3":"mli","name":"Мали"}, -{"id":462,"alpha2":"mv","alpha3":"mdv","name":"Мальдивы"}, -{"id":470,"alpha2":"mt","alpha3":"mlt","name":"Мальта"}, -{"id":504,"alpha2":"ma","alpha3":"mar","name":"Марокко"}, -{"id":584,"alpha2":"mh","alpha3":"mhl","name":"Маршалловы Острова"}, -{"id":484,"alpha2":"mx","alpha3":"mex","name":"Мексика"}, -{"id":583,"alpha2":"fm","alpha3":"fsm","name":"Микронезия"}, -{"id":508,"alpha2":"mz","alpha3":"moz","name":"Мозамбик"}, -{"id":498,"alpha2":"md","alpha3":"mda","name":"Молдова"}, -{"id":492,"alpha2":"mc","alpha3":"mco","name":"Монако"}, -{"id":496,"alpha2":"mn","alpha3":"mng","name":"Монголия"}, -{"id":104,"alpha2":"mm","alpha3":"mmr","name":"Мьянма"}, -{"id":516,"alpha2":"na","alpha3":"nam","name":"Намибия"}, -{"id":520,"alpha2":"nr","alpha3":"nru","name":"Науру"}, -{"id":524,"alpha2":"np","alpha3":"npl","name":"Непал"}, -{"id":562,"alpha2":"ne","alpha3":"ner","name":"Нигер"}, -{"id":566,"alpha2":"ng","alpha3":"nga","name":"Нигерия"}, -{"id":528,"alpha2":"nl","alpha3":"nld","name":"Нидерланды"}, -{"id":558,"alpha2":"ni","alpha3":"nic","name":"Никарагуа"}, -{"id":554,"alpha2":"nz","alpha3":"nzl","name":"Новая Зеландия"}, -{"id":578,"alpha2":"no","alpha3":"nor","name":"Норвегия"}, -{"id":784,"alpha2":"ae","alpha3":"are","name":"ОАЭ"}, -{"id":512,"alpha2":"om","alpha3":"omn","name":"Оман"}, -{"id":586,"alpha2":"pk","alpha3":"pak","name":"Пакистан"}, -{"id":585,"alpha2":"pw","alpha3":"plw","name":"Палау"}, -{"id":591,"alpha2":"pa","alpha3":"pan","name":"Панама"}, -{"id":598,"alpha2":"pg","alpha3":"png","name":"Папуа — Новая Гвинея"}, -{"id":600,"alpha2":"py","alpha3":"pry","name":"Парагвай"}, -{"id":604,"alpha2":"pe","alpha3":"per","name":"Перу"}, -{"id":616,"alpha2":"pl","alpha3":"pol","name":"Польша"}, -{"id":620,"alpha2":"pt","alpha3":"prt","name":"Португалия"}, -{"id":178,"alpha2":"cg","alpha3":"cog","name":"Республика Конго"}, -{"id":410,"alpha2":"kr","alpha3":"kor","name":"Республика Корея"}, -{"id":643,"alpha2":"ru","alpha3":"rus","name":"Россия"}, -{"id":646,"alpha2":"rw","alpha3":"rwa","name":"Руанда"}, -{"id":642,"alpha2":"ro","alpha3":"rou","name":"Румыния"}, -{"id":222,"alpha2":"sv","alpha3":"slv","name":"Сальвадор"}, -{"id":882,"alpha2":"ws","alpha3":"wsm","name":"Самоа"}, -{"id":674,"alpha2":"sm","alpha3":"smr","name":"Сан-Марино"}, -{"id":678,"alpha2":"st","alpha3":"stp","name":"Сан-Томе и Принсипи"}, -{"id":682,"alpha2":"sa","alpha3":"sau","name":"Саудовская Аравия"}, -{"id":690,"alpha2":"sc","alpha3":"syc","name":"Сейшельские Острова"}, -{"id":686,"alpha2":"sn","alpha3":"sen","name":"Сенегал"}, -{"id":670,"alpha2":"vc","alpha3":"vct","name":"Сент-Винсент и Гренадины"}, -{"id":659,"alpha2":"kn","alpha3":"kna","name":"Сент-Китс и Невис"}, -{"id":662,"alpha2":"lc","alpha3":"lca","name":"Сент-Люсия"}, -{"id":688,"alpha2":"rs","alpha3":"srb","name":"Сербия"}, -{"id":702,"alpha2":"sg","alpha3":"sgp","name":"Сингапур"}, -{"id":760,"alpha2":"sy","alpha3":"syr","name":"Сирия"}, -{"id":703,"alpha2":"sk","alpha3":"svk","name":"Словакия"}, -{"id":705,"alpha2":"si","alpha3":"svn","name":"Словения"}, -{"id":90,"alpha2":"sb","alpha3":"slb","name":"Соломоновы Острова"}, -{"id":706,"alpha2":"so","alpha3":"som","name":"Сомали"}, -{"id":729,"alpha2":"sd","alpha3":"sdn","name":"Судан"}, -{"id":740,"alpha2":"sr","alpha3":"sur","name":"Суринам"}, -{"id":840,"alpha2":"us","alpha3":"usa","name":"США"}, -{"id":694,"alpha2":"sl","alpha3":"sle","name":"Сьерра-Леоне"}, -{"id":762,"alpha2":"tj","alpha3":"tjk","name":"Таджикистан"}, -{"id":764,"alpha2":"th","alpha3":"tha","name":"Таиланд"}, -{"id":834,"alpha2":"tz","alpha3":"tza","name":"Танзания"}, -{"id":768,"alpha2":"tg","alpha3":"tgo","name":"Того"}, -{"id":776,"alpha2":"to","alpha3":"ton","name":"Тонга"}, -{"id":780,"alpha2":"tt","alpha3":"tto","name":"Тринидад и Тобаго"}, -{"id":798,"alpha2":"tv","alpha3":"tuv","name":"Тувалу"}, -{"id":788,"alpha2":"tn","alpha3":"tun","name":"Тунис"}, -{"id":795,"alpha2":"tm","alpha3":"tkm","name":"Туркменистан"}, -{"id":792,"alpha2":"tr","alpha3":"tur","name":"Турция"}, -{"id":800,"alpha2":"ug","alpha3":"uga","name":"Уганда"}, -{"id":860,"alpha2":"uz","alpha3":"uzb","name":"Узбекистан"}, -{"id":804,"alpha2":"ua","alpha3":"ukr","name":"Украина"}, -{"id":858,"alpha2":"uy","alpha3":"ury","name":"Уругвай"}, -{"id":242,"alpha2":"fj","alpha3":"fji","name":"Фиджи"}, -{"id":608,"alpha2":"ph","alpha3":"phl","name":"Филиппины"}, -{"id":246,"alpha2":"fi","alpha3":"fin","name":"Финляндия"}, -{"id":250,"alpha2":"fr","alpha3":"fra","name":"Франция"}, -{"id":191,"alpha2":"hr","alpha3":"hrv","name":"Хорватия"}, -{"id":140,"alpha2":"cf","alpha3":"caf","name":"ЦАР"}, -{"id":148,"alpha2":"td","alpha3":"tcd","name":"Чад"}, -{"id":499,"alpha2":"me","alpha3":"mne","name":"Черногория"}, -{"id":203,"alpha2":"cz","alpha3":"cze","name":"Чехия"}, -{"id":152,"alpha2":"cl","alpha3":"chl","name":"Чили"}, -{"id":756,"alpha2":"ch","alpha3":"che","name":"Швейцария"}, -{"id":752,"alpha2":"se","alpha3":"swe","name":"Швеция"}, -{"id":144,"alpha2":"lk","alpha3":"lka","name":"Шри-Ланка"}, -{"id":218,"alpha2":"ec","alpha3":"ecu","name":"Эквадор"}, -{"id":226,"alpha2":"gq","alpha3":"gnq","name":"Экваториальная Гвинея"}, -{"id":232,"alpha2":"er","alpha3":"eri","name":"Эритрея"}, -{"id":748,"alpha2":"sz","alpha3":"swz","name":"Эсватини"}, -{"id":233,"alpha2":"ee","alpha3":"est","name":"Эстония"}, -{"id":231,"alpha2":"et","alpha3":"eth","name":"Эфиопия"}, -{"id":710,"alpha2":"za","alpha3":"zaf","name":"ЮАР"}, -{"id":728,"alpha2":"ss","alpha3":"ssd","name":"Южный Судан"}, -{"id":388,"alpha2":"jm","alpha3":"jam","name":"Ямайка"}, -{"id":392,"alpha2":"jp","alpha3":"jpn","name":"Япония"} +{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Австралия", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Австрия", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Азербайджан", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Албания", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Алжир", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Ангола", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Андорра", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]} +{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Антигуа и Барбуда", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Аргентина", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Армения", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Афганистан", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Багамские Острова", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Бангладеш", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]} +{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Барбадос", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Бахрейн", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Белиз", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} +{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Беларусь", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]} +{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Бельгия", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Бенин", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Болгария", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Боливия", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]} +{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Босния и Герцеговина", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]} +{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Ботсвана", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Бразилия", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Бруней", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Буркина-Фасо", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Бурунди", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Бутан", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Вануату", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "Великобритания", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Венгрия", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Венесуэла", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Восточный Тимор", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Вьетнам", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Габон", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Гаити", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Гайана", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} +{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Гамбия", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Гана", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Гватемала", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Гвинея", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Гвинея-Бисау", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Германия", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]} +{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Гондурас", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]} +{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Гренада", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Греция", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Грузия", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Дания", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Джибути", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Доминика", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Доминиканская Республика", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "ДР Конго", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Египет", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Замбия", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Зимбабве", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Израиль", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]} +{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "Индия", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]} +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Иордания", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Ирак", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Иран", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ирландия", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Исландия", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Испания", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Италия", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Йемен", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Кабо-Верде", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Казахстан", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]} +{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Камбоджа", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Камерун", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Канада", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Катар", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Кения", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Кипр", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Кыргызстан", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Кирибати", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "КНДР (Корейская Народно-Демократическая Республика)", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "Китай (Китайская Народная Республика)", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Колумбия", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Коморы", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Коста-Рика", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Кот-д’Ивуар", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} +{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Куба", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]} +{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Кувейт", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Лаос", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Латвия", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Лесото", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Либерия", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Ливан", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Ливия", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Литва", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]} +{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Лихтенштейн", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Люксембург", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Маврикий", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Мавритания", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Мадагаскар", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "Северная Македония", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Малави", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Малайзия", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]} +{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Мали", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Мальдивы", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Мальта", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Марокко", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]} +{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Маршалловы Острова", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Мексика", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Микронезия", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Мозамбик", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Молдова", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Монако", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Монголия", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Мьянма", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Намибия", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Науру", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Непал", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} +{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Нигер", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Нигерия", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Нидерланды", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} +{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Никарагуа", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "Новая Зеландия", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} +{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Норвегия", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "ОАЭ", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Оман", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Пакистан", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]} +{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Палау", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Панама", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Папуа — Новая Гвинея", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Парагвай", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Перу", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Польша", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Португалия", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Республика Конго", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Республика Корея", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]} +{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Россия", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]} +{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Руанда", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Румыния", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "Сальвадор", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Самоа", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]} +{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "Сан-Марино", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} +{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Сан-Томе и Принсипи", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Саудовская Аравия", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Сейшельские Острова", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Сенегал", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Сент-Винсент и Гренадины", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Сент-Китс и Невис", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Сент-Люсия", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Сербия", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Сингапур", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Сирия", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Словакия", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Словения", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Соломоновы Острова", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Сомали", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Судан", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Суринам", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} +{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "США", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Сьерра-Леоне", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]} +{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Таджикистан", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Таиланд", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} +{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Танзания", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Того", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Тонга", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Тринидад и Тобаго", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Тувалу", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]} +{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Тунис", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Туркменистан", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Турция", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Уганда", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Узбекистан", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Украина", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Уругвай", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Фиджи", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Филиппины", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Финляндия", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "Франция", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} +{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Хорватия", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "ЦАР", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Чад", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Черногория", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Чехия", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Чили", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Швейцария", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Швеция", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]} +{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Шри-Ланка", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Эквадор", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Экваториальная Гвинея", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Эритрея", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Эсватини", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} +{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Эстония", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} +{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Эфиопия", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "ЮАР", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "Южный Судан", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Ямайка", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Япония", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]} ]'''; diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/pubspec.lock b/pubspec.lock index bc155f7..6bb2187 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -196,18 +196,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -289,10 +289,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" timezone: dependency: "direct main" description: From 65010cccfc26f5d9b485efca6a490b7fa0ae471b Mon Sep 17 00:00:00 2001 From: noxzion Date: Mon, 16 Mar 2026 08:09:19 +0500 Subject: [PATCH 010/323] =?UTF-8?q?=D0=92=D1=8B=D0=B1=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/countries.dart | 472 ++++++++++++++++++ .../auth/code_confirmation_screen.dart | 1 - lib/frontend/screens/auth/login_screen.dart | 198 ++++++-- .../screens/chats/chat_list_screen.dart | 6 +- lib/main.dart | 2 +- pubspec.lock | 56 +++ 6 files changed, 689 insertions(+), 46 deletions(-) create mode 100644 lib/core/config/countries.dart diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart new file mode 100644 index 0000000..171126d --- /dev/null +++ b/lib/core/config/countries.dart @@ -0,0 +1,472 @@ +import 'dart:convert'; + +class CountryName { + final String code; // ISO 3166-1 alpha-2, например "RU" + final String en; + final String ru; + final String phoneCode; // Код страны для звонков, например "+7" + final int phoneDigits; // Количество цифр номера после кода страны + final String phoneMask; // Маска абонентского номера, например "(###) ###-##-##" + final List phoneGroupSizes; // Размеры групп цифр, например [3, 3, 2, 2] + final List phoneGroupSeparators; // Разделители вокруг групп, например ["(", ") ", "-", "-", ""] + + const CountryName({ + required this.code, + required this.en, + required this.ru, + required this.phoneCode, + required this.phoneDigits, + required this.phoneMask, + required this.phoneGroupSizes, + required this.phoneGroupSeparators, + }); +} + +/// Полный список стран (195 государств) с названием на русском и английском. +/// +/// Источник данных: +/// https://github.com/stefangabos/world_countries +final List allCountries = _buildCountries(); + +/// Быстрый доступ по ISO-коду. +final Map countriesByCode = { + for (final country in allCountries) country.code: country, +}; + +/// Пример использования: +/// ```dart +/// final country = exampleCountryLookup('RU'); +/// print(country?['ru']); // Россия +/// print(country?['en']); // Russian Federation +/// ``` +Map? exampleCountryLookup(String code) { + final country = countriesByCode[code.toUpperCase()]; + if (country == null) return null; + + return { + 'code': country.code, + 'ru': country.ru, + 'en': country.en, + }; +} + +List _buildCountries() { + final enList = (jsonDecode(_countriesEnJson) as List) + .cast>(); + final ruList = (jsonDecode(_countriesRuJson) as List) + .cast>(); + + final ruByCode = { + for (final item in ruList) + (item['alpha2'] as String).toUpperCase(): item['name'] as String, + }; + + final countries = enList.map((item) { + final code = (item['alpha2'] as String).toUpperCase(); + final en = item['name'] as String; + final ru = ruByCode[code] ?? en; + + final phoneCode = item['phoneCode'] as String; + final phoneDigits = item['phoneDigits'] as int; + final phoneMask = item['phoneMask'] as String; + final phoneGroupSizes = (item['phoneGroupSizes'] as List).cast(); + final phoneGroupSeparators = (item['phoneGroupSeparators'] as List).cast(); + + return CountryName(code: code, en: en, ru: ru, phoneCode: phoneCode, phoneDigits: phoneDigits, phoneMask: phoneMask, phoneGroupSizes: phoneGroupSizes, phoneGroupSeparators: phoneGroupSeparators); + }).toList(); + + countries.sort((a, b) => a.en.compareTo(b.en)); + return countries; +} + +const String _countriesEnJson = '''[ +{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Afghanistan", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Albania", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Algeria", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Andorra", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Angola", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Antigua and Barbuda", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Argentina", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Armenia", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Australia", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Austria", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Azerbaijan", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Bahamas", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Bahrain", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Bangladesh", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Barbados", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Belarus", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]}, +{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Belgium", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Belize", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Benin", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Bhutan", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Bolivia, Plurinational State of", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Bosnia and Herzegovina", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]}, +{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Botswana", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Brazil", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Brunei Darussalam", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Bulgaria", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Burkina Faso", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Burundi", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Cabo Verde", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Cambodia", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Cameroon", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Canada", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "Central African Republic", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Chad", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Chile", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "China", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Colombia", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Comoros", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Congo", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "Congo, Democratic Republic of the", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Costa Rica", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Côte d'Ivoire", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Croatia", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Cuba", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Cyprus", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Czechia", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Denmark", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Djibouti", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Dominica", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Dominican Republic", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Ecuador", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Egypt", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "El Salvador", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Equatorial Guinea", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Eritrea", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Estonia", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Eswatini", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Ethiopia", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Fiji", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Finland", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "France", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Gabon", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Gambia", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Georgia", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Germany", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Ghana", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Greece", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Grenada", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Guatemala", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Guinea", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Guinea-Bissau", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Guyana", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Haiti", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Honduras", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Hungary", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Iceland", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "India", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Iran, Islamic Republic of", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Iraq", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ireland", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Israel", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Italy", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Jamaica", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Japan", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Jordan", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Kazakhstan", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Kenya", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Kiribati", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "Korea, Democratic People's Republic of", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Korea, Republic of", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Kuwait", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Kyrgyzstan", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Lao People's Democratic Republic", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Latvia", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Lebanon", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Lesotho", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Liberia", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Libya", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Liechtenstein", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Lithuania", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Luxembourg", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Madagascar", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Malawi", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Malaysia", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Maldives", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Mali", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Malta", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Marshall Islands", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Mauritania", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Mauritius", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Mexico", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Micronesia, Federated States of", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Moldova, Republic of", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Monaco", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Mongolia", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Montenegro", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Morocco", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Mozambique", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Myanmar", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Namibia", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Nauru", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Nepal", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Netherlands", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "New Zealand", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Nicaragua", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Niger", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Nigeria", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "North Macedonia", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Norway", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Oman", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Pakistan", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Palau", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Panama", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Papua New Guinea", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Paraguay", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Peru", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Philippines", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Poland", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Portugal", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Qatar", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Romania", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Russian Federation", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Rwanda", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Saint Kitts and Nevis", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Saint Lucia", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Saint Vincent and the Grenadines", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Samoa", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "San Marino", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Sao Tome and Principe", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Saudi Arabia", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Senegal", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Serbia", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Seychelles", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Sierra Leone", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Singapore", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Slovakia", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Slovenia", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Solomon Islands", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Somalia", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "South Africa", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "South Sudan", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Spain", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Sri Lanka", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Sudan", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Suriname", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Sweden", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]}, +{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Switzerland", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Syrian Arab Republic", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Tajikistan", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Tanzania, United Republic of", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Thailand", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Timor-Leste", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Togo", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Tonga", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Trinidad and Tobago", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Tunisia", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Türkiye", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Turkmenistan", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Tuvalu", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Uganda", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Ukraine", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "United Arab Emirates", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "United Kingdom of Great Britain and Northern Ireland", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "United States of America", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Uruguay", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Uzbekistan", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Vanuatu", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Venezuela, Bolivarian Republic of", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Viet Nam", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Yemen", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Zambia", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Zimbabwe", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +]'''; + +const String _countriesRuJson = '''[ +{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Австралия", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Австрия", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Азербайджан", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Албания", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Алжир", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Ангола", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Андорра", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Антигуа и Барбуда", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Аргентина", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Армения", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Афганистан", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Багамские Острова", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Бангладеш", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Барбадос", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Бахрейн", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Белиз", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Беларусь", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]}, +{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Бельгия", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Бенин", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Болгария", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Боливия", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Босния и Герцеговина", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]}, +{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Ботсвана", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Бразилия", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Бруней", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Буркина-Фасо", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Бурунди", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Бутан", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Вануату", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "Великобритания", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Венгрия", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Венесуэла", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Восточный Тимор", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Вьетнам", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Габон", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Гаити", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Гайана", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Гамбия", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Гана", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Гватемала", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Гвинея", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Гвинея-Бисау", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Германия", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Гондурас", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Гренада", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Греция", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Грузия", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Дания", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Джибути", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Доминика", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Доминиканская Республика", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "ДР Конго", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Египет", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Замбия", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Зимбабве", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Израиль", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "Индия", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Иордания", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Ирак", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Иран", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ирландия", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Исландия", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Испания", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Италия", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Йемен", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Кабо-Верде", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Казахстан", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Камбоджа", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Камерун", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Канада", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Катар", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Кения", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Кипр", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Кыргызстан", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Кирибати", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "КНДР (Корейская Народно-Демократическая Республика)", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "Китай (Китайская Народная Республика)", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Колумбия", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Коморы", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Коста-Рика", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Кот-д’Ивуар", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Куба", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Кувейт", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Лаос", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Латвия", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Лесото", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Либерия", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Ливан", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Ливия", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Литва", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Лихтенштейн", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Люксембург", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Маврикий", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Мавритания", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Мадагаскар", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "Северная Македония", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Малави", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Малайзия", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Мали", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Мальдивы", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Мальта", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Марокко", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Маршалловы Острова", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Мексика", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Микронезия", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Мозамбик", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Молдова", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Монако", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Монголия", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Мьянма", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Намибия", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Науру", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Непал", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Нигер", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Нигерия", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Нидерланды", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Никарагуа", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "Новая Зеландия", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Норвегия", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "ОАЭ", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Оман", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Пакистан", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Палау", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Панама", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Папуа — Новая Гвинея", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Парагвай", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Перу", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Польша", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Португалия", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Республика Конго", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Республика Корея", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Россия", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Руанда", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Румыния", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "Сальвадор", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Самоа", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "Сан-Марино", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Сан-Томе и Принсипи", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Саудовская Аравия", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Сейшельские Острова", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Сенегал", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Сент-Винсент и Гренадины", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Сент-Китс и Невис", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Сент-Люсия", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Сербия", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Сингапур", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Сирия", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Словакия", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Словения", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Соломоновы Острова", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Сомали", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Судан", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Суринам", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "США", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Сьерра-Леоне", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Таджикистан", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Таиланд", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Танзания", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Того", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Тонга", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Тринидад и Тобаго", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Тувалу", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Тунис", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Туркменистан", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Турция", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Уганда", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Узбекистан", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Украина", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Уругвай", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Фиджи", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Филиппины", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Финляндия", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "Франция", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Хорватия", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "ЦАР", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Чад", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Черногория", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Чехия", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Чили", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Швейцария", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Швеция", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]}, +{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Шри-Ланка", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Эквадор", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Экваториальная Гвинея", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Эритрея", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Эсватини", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Эстония", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Эфиопия", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "ЮАР", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "Южный Судан", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Ямайка", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Япония", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]} +]'''; \ No newline at end of file diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 39b828b..cd9861f 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:material_symbols_icons/symbols.dart'; import '../chats/chat_list_screen.dart'; class CodeConfirmationScreen extends StatefulWidget { diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 700742c..d950724 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/core/config/countries.dart'; import 'code_confirmation_screen.dart'; class LoginScreen extends StatefulWidget { @@ -14,8 +15,89 @@ class LoginScreen extends StatefulWidget { class _LoginScreenState extends State { final TextEditingController _phoneController = TextEditingController(); + late CountryName _selectedCountry; bool _isPhoneValid = false; + @override + void initState() { + super.initState(); + _selectedCountry = countriesByCode['RU'] ?? allCountries.first; + } + + void _showCountryPicker() { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1E1E2A), + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 1.0, + snap: true, + snapSizes: const [0.7, 1.0], + expand: false, + builder: (context, scrollController) => Column( + children: [ + Padding( + padding: const EdgeInsets.all(24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Выберите страну', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Symbols.close, color: Colors.white70), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + controller: scrollController, + itemCount: allCountries.length, + itemBuilder: (context, index) { + final country = allCountries[index]; + return ListTile( + leading: Text( + country.phoneCode, + style: const TextStyle(color: Colors.white54, fontSize: 14), + ), + title: Text( + country.ru, + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + onTap: () { + setState(() { + _selectedCountry = country; + _phoneController.clear(); + _isPhoneValid = false; + }); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } + void _showTOS(BuildContext context) { showModalBottomSheet( context: context, @@ -205,8 +287,8 @@ class _LoginScreenState extends State { ), const SizedBox(height: 12), Text( - '+7 $formattedPhone', - style: const TextStyle( + '${_selectedCountry.phoneCode} $formattedPhone', + style: GoogleFonts.inter( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w500, @@ -220,10 +302,10 @@ class _LoginScreenState extends State { children: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text( + child: Text( 'Изменить', - style: TextStyle( - color: Color(0xFFAFAFFF), + style: GoogleFonts.inter( + color: const Color(0xFFAFAFFF), fontSize: 15, fontWeight: FontWeight.w500, ), @@ -236,15 +318,15 @@ class _LoginScreenState extends State { context, MaterialPageRoute( builder: (context) => CodeConfirmationScreen( - phoneNumber: '+7 $formattedPhone', + phoneNumber: '${_selectedCountry.phoneCode} $formattedPhone', ), ), ); }, - child: const Text( + child: Text( 'Готово', - style: TextStyle( - color: Color(0xFFAFAFFF), + style: GoogleFonts.inter( + color: const Color(0xFFAFAFFF), fontSize: 15, fontWeight: FontWeight.w600, ), @@ -288,7 +370,7 @@ class _LoginScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 16), + const SizedBox(height: 44), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -310,7 +392,7 @@ class _LoginScreenState extends State { ), ], ), - const SizedBox(height: 24), + const SizedBox(height: 74), Center( child: Column( children: [ @@ -324,7 +406,7 @@ class _LoginScreenState extends State { 'Войдите в Komet', style: GoogleFonts.inter( color: Colors.white, - fontSize: 20, + fontSize: 32, fontWeight: FontWeight.w500, ), ), @@ -334,7 +416,7 @@ class _LoginScreenState extends State { textAlign: TextAlign.center, style: TextStyle( color: Colors.white70, - fontSize: 13, + fontSize: 16, fontWeight: FontWeight.w400, height: 1.4, ), @@ -342,29 +424,41 @@ class _LoginScreenState extends State { ], ), ), - const SizedBox(height: 24), - _buildInputField( - label: 'Страна', - content: Row( - children: [ - const Text( - 'Россия', - style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), - ), - const Spacer(), - const Icon(Icons.keyboard_arrow_down, color: Colors.white70), - ], + const SizedBox(height: 48), + InkWell( + onTap: _showCountryPicker, + borderRadius: BorderRadius.circular(50), + child: _buildInputField( + label: 'Страна', + content: Row( + children: [ + Text( + _selectedCountry.ru, + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + ), + const Spacer(), + const Icon(Icons.keyboard_arrow_down, color: Colors.white70), + ], + ), ), ), - const SizedBox(height: 12), + const SizedBox(height: 40), _buildInputField( label: 'Номер телефона', content: Row( children: [ - const Text( - '+7', - style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), - ), + Text( + _selectedCountry.phoneCode, + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + ), const SizedBox(width: 12), Container( width: 1, @@ -378,7 +472,7 @@ class _LoginScreenState extends State { keyboardType: TextInputType.phone, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, - _PhoneInputFormatter(), + _PhoneInputFormatter(_selectedCountry), ], style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), decoration: const InputDecoration( @@ -391,7 +485,7 @@ class _LoginScreenState extends State { onChanged: (value) { final digits = value.replaceAll(RegExp(r'\D'), ''); setState(() { - _isPhoneValid = digits.length == 10; + _isPhoneValid = digits.length == _selectedCountry.phoneDigits; }); }, ), @@ -423,7 +517,7 @@ class _LoginScreenState extends State { text: TextSpan( style: GoogleFonts.inter( color: Colors.white, - fontSize: 11, + fontSize: 14, fontWeight: FontWeight.w500, height: 1.4, ), @@ -512,8 +606,8 @@ class _LoginScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( label, - style: const TextStyle( - color: Color(0xFFBEC2FF), + style: GoogleFonts.inter( + color: const Color(0xFFBEC2FF), fontSize: 14, fontWeight: FontWeight.w400, ), @@ -528,6 +622,9 @@ class _LoginScreenState extends State { } class _PhoneInputFormatter extends TextInputFormatter { + final CountryName country; + _PhoneInputFormatter(this.country); + @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue) { @@ -540,14 +637,33 @@ class _PhoneInputFormatter extends TextInputFormatter { } } - if (text.length > 10) text = text.substring(0, 10); + if (text.length > country.phoneDigits) { + text = text.substring(0, country.phoneDigits); + } final buffer = StringBuffer(); - for (int i = 0; i < text.length; i++) { - if (i == 0) buffer.write('('); - buffer.write(text[i]); - if (i == 2) buffer.write(') '); - if (i == 5) buffer.write('-'); + int digitIdx = 0; + + for (int i = 0; i < country.phoneGroupSizes.length; i++) { + if (digitIdx >= text.length) break; + + buffer.write(country.phoneGroupSeparators[i]); + + final groupSize = country.phoneGroupSizes[i]; + final remainingDigits = text.length - digitIdx; + final digitsToTake = remainingDigits < groupSize ? remainingDigits : groupSize; + + buffer.write(text.substring(digitIdx, digitIdx + digitsToTake)); + digitIdx += digitsToTake; + + if (digitIdx == text.length && i < country.phoneGroupSeparators.length - 1) { + } + } + + if (digitIdx == text.length && text.length == country.phoneDigits) { + if (country.phoneGroupSeparators.length > country.phoneGroupSizes.length) { + buffer.write(country.phoneGroupSeparators.last); + } } final formattedText = buffer.toString(); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index d2e7c66..5358b67 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -314,13 +314,13 @@ class _ChatListScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: isSelected ? const Color(0xFFC1C4FF) : const Color(0xFF1E1E2A), - borderRadius: BorderRadius.circular(15), + borderRadius: BorderRadius.circular(10), ), child: Text( title, style: TextStyle( - color: isSelected ? Colors.black : Colors.white54, - fontSize: 13, + color: isSelected ? Colors.black : Color(0xFFC1C4FF), + fontSize: 14, fontWeight: FontWeight.w500, ), ), diff --git a/lib/main.dart b/lib/main.dart index f273ad7..0bd0220 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:komet/frontend/screens/auth/login_screen.dart'; -void main() { +void main() async { runApp(const MyApp()); } diff --git a/pubspec.lock b/pubspec.lock index 150913d..6a90adc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -155,6 +155,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" leak_tracker: dependency: transitive description: @@ -339,6 +347,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" sky_engine: dependency: transitive description: flutter @@ -424,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" + url: "https://pub.dev" + source: hosted + version: "0.5.1" xdg_directories: dependency: transitive description: From f843f467e9614d6c5606861fa2bb6233e84271b5 Mon Sep 17 00:00:00 2001 From: noxzion Date: Tue, 17 Mar 2026 16:36:58 +0500 Subject: [PATCH 011/323] =?UTF-8?q?=D0=AD=D0=BA=D1=80=D0=B0=D0=BD=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D0=BE=D1=80=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B,=20=D0=B4=D0=B8=D0=BD=D0=B0=D0=BC=D0=B8=D0=BA=20=D1=86?= =?UTF-8?q?=D0=B2=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/gradle.properties | 1 + .../auth/code_confirmation_screen.dart | 37 +- lib/frontend/screens/auth/login_screen.dart | 421 ++++++++---------- .../screens/auth/select_country_screen.dart | 135 ++++++ .../screens/chats/chat_list_screen.dart | 87 ++-- lib/main.dart | 53 ++- pubspec.lock | 64 +-- pubspec.yaml | 1 + 8 files changed, 436 insertions(+), 363 deletions(-) create mode 100644 lib/frontend/screens/auth/select_country_screen.dart diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..23bd6e3 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,3 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +kotlin.incremental=false diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index cd9861f..0ea6a65 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -53,7 +53,6 @@ class _CodeConfirmationScreenState extends State { void _resendCode() { if (_timerSeconds == 0) { _startTimer(); - // TODO: Implement actual resend logic here print('Resending code to ${widget.phoneNumber}'); } } @@ -68,13 +67,14 @@ class _CodeConfirmationScreenState extends State { @override Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; return Scaffold( - backgroundColor: const Color(0xFF0D0D12), + backgroundColor: cs.surface, appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white70), + icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant), onPressed: () => Navigator.pop(context), ), ), @@ -88,16 +88,16 @@ class _CodeConfirmationScreenState extends State { Text( widget.phoneNumber, style: GoogleFonts.inter( - color: Colors.white, + color: cs.onSurface, fontSize: 22, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 12), - const Text( + Text( 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.', style: TextStyle( - color: Colors.white54, + color: cs.outline, fontSize: 15, fontWeight: FontWeight.w400, height: 1.4, @@ -129,7 +129,6 @@ class _CodeConfirmationScreenState extends State { ), ), ), - GestureDetector( onTap: () => _focusNode.requestFocus(), child: FittedBox( @@ -144,12 +143,12 @@ class _CodeConfirmationScreenState extends State { height: 54, margin: EdgeInsets.only(right: index == 4 ? 0 : 10), decoration: BoxDecoration( - color: const Color(0xFF1E1E2A), + color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), border: Border.all( color: isFocused - ? const Color(0xFFBEC2FF) - : (hasValue ? Colors.white24 : Colors.transparent), + ? cs.primary + : (hasValue ? cs.outlineVariant : Colors.transparent), width: 1.5, ), ), @@ -165,8 +164,8 @@ class _CodeConfirmationScreenState extends State { child: Text( char, key: ValueKey(char + index.toString()), - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: cs.onSurface, fontSize: 20, fontWeight: FontWeight.w600, ), @@ -183,11 +182,11 @@ class _CodeConfirmationScreenState extends State { GestureDetector( onTap: _resendCode, child: Text( - _timerSeconds > 0 - ? 'Отправить повторно через $_timerSeconds сек.' + _timerSeconds > 0 + ? 'Отправить повторно через $_timerSeconds сек.' : 'Отправить код по SMS', - style: const TextStyle( - color: Color(0xFF63A9F5), + style: TextStyle( + color: cs.tertiary, fontSize: 14, fontWeight: FontWeight.w400, ), @@ -204,15 +203,15 @@ class _CodeConfirmationScreenState extends State { } }, backgroundColor: _codeController.text.length == 5 - ? const Color(0xffc1c4ff) - : const Color(0xFF1E1E2A), + ? cs.primaryContainer + : cs.surfaceContainerHighest, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50), ), child: Icon( Icons.arrow_forward, - color: _codeController.text.length == 5 ? Colors.black : Colors.white24, + color: _codeController.text.length == 5 ? cs.onPrimaryContainer : cs.onSurfaceVariant, ), ), ], diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index d950724..fb375b4 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/countries.dart'; import 'code_confirmation_screen.dart'; +import 'select_country_screen.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -24,228 +25,176 @@ class _LoginScreenState extends State { _selectedCountry = countriesByCode['RU'] ?? allCountries.first; } - void _showCountryPicker() { - showModalBottomSheet( - context: context, - backgroundColor: const Color(0xFF1E1E2A), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 1.0, - snap: true, - snapSizes: const [0.7, 1.0], - expand: false, - builder: (context, scrollController) => Column( - children: [ - Padding( - padding: const EdgeInsets.all(24.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Выберите страну', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w500, - ), - ), - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Symbols.close, color: Colors.white70), - ), - ], - ), - ), - Expanded( - child: ListView.builder( - controller: scrollController, - itemCount: allCountries.length, - itemBuilder: (context, index) { - final country = allCountries[index]; - return ListTile( - leading: Text( - country.phoneCode, - style: const TextStyle(color: Colors.white54, fontSize: 14), - ), - title: Text( - country.ru, - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w400, - ), - ), - onTap: () { - setState(() { - _selectedCountry = country; - _phoneController.clear(); - _isPhoneValid = false; - }); - Navigator.pop(context); - }, - ); - }, - ), - ), - ], + void _showCountryPicker() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SelectCountryScreen( + selectedCountry: _selectedCountry, ), ), ); + if (result != null) { + setState(() { + _selectedCountry = result; + _phoneController.clear(); + _isPhoneValid = false; + }); + } } void _showTOS(BuildContext context) { + final cs = Theme.of(context).colorScheme; showModalBottomSheet( context: context, - backgroundColor: const Color(0xFF1E1E2A), + backgroundColor: cs.surfaceContainerHigh, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 1.0, - snap: true, - snapSizes: const [0.7, 1.0], - expand: false, - builder: (context, scrollController) => Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Условия использования', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w500, - ), - ), - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Symbols.close, color: Colors.white70), - ), - ], - ), - const SizedBox(height: 16), - Expanded( - child: ListView( - controller: scrollController, - children: const [ + builder: (context) { + final cs = Theme.of(context).colorScheme; + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 1.0, + snap: true, + snapSizes: const [0.7, 1.0], + expand: false, + builder: (context, scrollController) => Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ Text( - 'Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"\n\n' - '1. Статус и отношения\n' - '1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX").\n' - '1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».\n' - '1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.\n\n' - '2. Условия использования\n' - '2.1. Используя Приложение «Komet», вы:\n' - '• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)\n' - '• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;\n' - '• Принимаете на себя все риски, связанные с использованием Приложения.\n' - '2.2. Строго запрещено:\n' - '• Использовать Приложение «Komet» для распространения запрещённого контента;\n' - '• Осуществлять массовые рассылки (спам);\n' - '• Нарушать законодательство РФ и международное право;\n' - '• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».\n' - '2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.\n' - '2.4. Особенности технического взаимодействия:\n' - '• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)\n' - '• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;\n' - '• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.\n\n' - '3. Технические аспекты\n' - '3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.\n' - '3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.\n' - '3.3. Разработчики не несут ответственности за:\n' - '• Изменения в API оригинального сервиса;\n' - '• Блокировку аккаунтов пользователей;\n' - '• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».\n\n' - '4. Конфиденциальность\n' - '4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.\n' - '4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».\n' - '4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.\n\n' - '5. Ответственность и ограничения\n' - '5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.\n' - '5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.\n\n' - '6. Правовые основания\n' - '6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:\n' - '• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;\n' - '• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;\n' - '• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;\n' - '• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.\n' - '6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:\n' - '• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;\n' - '• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;\n' - '• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.\n' - '6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.\n\n' - '7. Заключительные положения\n' - '7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:\n' - '• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;\n' - '• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;\n' - '• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.\n' - '7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.\n\n' - '8. Функции безопасности и конфиденциальности\n' - '8.1. Приложение «Komet» включает инструменты защиты приватности:\n' - '• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT);\n' - '• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n' - '• Ограничение телеметрии — для минимизации передачи диагностических данных.\n' - '8.2. Данные функции:\n' - '• Направлены исключительно на защиту конфиденциальности пользователей;\n' - '• Не используются для обхода систем безопасности оригинального сервиса;\n' - '• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n' - '8.3. Разработчики не несут ответственности за:\n' - '• Блокировки, связанные с использованием инструментов конфиденциальности;\n' - '• Изменения в работе сервиса при активации данных функций.\n' - '8.4. Функции экспорта и импорта сессии\n' - '8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n' - '• Обеспечения переносимости данных между устройствами пользователя\n' - '• Резервного копирования учетных данных\n' - '• Восстановления доступа при утере устройства\n' - '8.4.2. Особенности реализации:\n' - '• Экспорт сессии осуществляется без привязки к номеру телефона\n' - '• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n' - '• Ключ шифрования известен только пользователю и не сохраняется в приложении\n' - '8.4.3. Техническая реализация экспорта сессии:\n' - '• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n' - '• Используется подмена параметров сессии для сохранения контекста аутентификации\n' - '• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n' - '• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n' - '• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n' - '• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n' - '8.4.4. Правовые основания:\n' - '• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n' - '• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n' - '• Принцип минимизации данных — сбор только необходимой для работы информации\n' - '• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n' - '• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n' - '• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n' - '8.4.5. Ограничения ответственности:\n' - '• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n' - '• Разработчики не имеют доступа к зашифрованным данным сессии\n' - '• Восстановление утерянных паролей невозможно в целях безопасности\n' - '• Ключи шифрования не хранятся в приложении и известны только пользователю', - style: TextStyle( - color: Colors.white70, - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1.5, + 'Условия использования', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w500, ), ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + ), ], ), - ), - ], + const SizedBox(height: 16), + Expanded( + child: ListView( + controller: scrollController, + children: [ + Text( + 'Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"\n\n' + '1. Статус и отношения\n' + '1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX").\n' + '1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».\n' + '1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.\n\n' + '2. Условия использования\n' + '2.1. Используя Приложение «Komet», вы:\n' + '• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)\n' + '• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;\n' + '• Принимаете на себя все риски, связанные с использованием Приложения.\n' + '2.2. Строго запрещено:\n' + '• Использовать Приложение «Komet» для распространения запрещённого контента;\n' + '• Осуществлять массовые рассылки (спам);\n' + '• Нарушать законодательство РФ и международное право;\n' + '• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».\n' + '2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.\n' + '2.4. Особенности технического взаимодействия:\n' + '• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)\n' + '• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;\n' + '• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.\n\n' + '3. Технические аспекты\n' + '3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.\n' + '3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.\n' + '3.3. Разработчики не несут ответственности за:\n' + '• Изменения в API оригинального сервиса;\n' + '• Блокировку аккаунтов пользователей;\n' + '• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».\n\n' + '4. Конфиденциальность\n' + '4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.\n' + '4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».\n' + '4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.\n\n' + '5. Ответственность и ограничения\n' + '5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.\n' + '5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.\n\n' + '6. Правовые основания\n' + '6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:\n' + '• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;\n' + '• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;\n' + '• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;\n' + '• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.\n' + '6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:\n' + '• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;\n' + '• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;\n' + '• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.\n' + '6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.\n\n' + '7. Заключительные положения\n' + '7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:\n' + '• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;\n' + '• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;\n' + '• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.\n' + '7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.\n\n' + '8. Функции безопасности и конфиденциальности\n' + '8.1. Приложение «Komet» включает инструменты защиты приватности:\n' + '• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT);\n' + '• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n' + '• Ограничение телеметрии — для минимизации передачи диагностических данных.\n' + '8.2. Данные функции:\n' + '• Направлены исключительно на защиту конфиденциальности пользователей;\n' + '• Не используются для обхода систем безопасности оригинального сервиса;\n' + '• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n' + '8.3. Разработчики не несут ответственности за:\n' + '• Блокировки, связанные с использованием инструментов конфиденциальности;\n' + '• Изменения в работе сервиса при активации данных функций.\n' + '8.4. Функции экспорта и импорта сессии\n' + '8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n' + '• Обеспечения переносимости данных между устройствами пользователя\n' + '• Резервного копирования учетных данных\n' + '• Восстановления доступа при утере устройства\n' + '8.4.2. Особенности реализации:\n' + '• Экспорт сессии осуществляется без привязки к номеру телефона\n' + '• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n' + '• Ключ шифрования известен только пользователю и не сохраняется в приложении\n' + '8.4.3. Техническая реализация экспорта сессии:\n' + '• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n' + '• Используется подмена параметров сессии для сохранения контекста аутентификации\n' + '• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n' + '• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n' + '• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n' + '• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n' + '8.4.4. Правовые основания:\n' + '• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n' + '• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n' + '• Принцип минимизации данных — сбор только необходимой для работы информации\n' + '• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n' + '• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n' + '• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n' + '8.4.5. Ограничения ответственности:\n' + '• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n' + '• Разработчики не имеют доступа к зашифрованным данным сессии\n' + '• Восстановление утерянных паролей невозможно в целях безопасности\n' + '• Ключи шифрования не хранятся в приложении и известны только пользователю', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.5, + ), + ), + ], + ), + ), + ], + ), ), - ), - ), + ); + }, ); } @@ -258,6 +207,7 @@ class _LoginScreenState extends State { transitionDuration: const Duration(milliseconds: 250), pageBuilder: (context, anim1, anim2) => const SizedBox.shrink(), transitionBuilder: (context, anim1, anim2, child) { + final cs = Theme.of(context).colorScheme; final curve = Curves.easeOutQuart.transform(anim1.value); return Opacity( opacity: anim1.value, @@ -266,7 +216,7 @@ class _LoginScreenState extends State { child: Transform.scale( scale: 0.8 + (0.2 * curve), child: AlertDialog( - backgroundColor: const Color(0xFF1E1E2A), + backgroundColor: cs.surfaceContainerHigh, surfaceTintColor: Colors.transparent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), @@ -280,7 +230,7 @@ class _LoginScreenState extends State { Text( 'Это правильный номер?', style: GoogleFonts.inter( - color: Colors.white70, + color: cs.onSurfaceVariant, fontSize: 14, fontWeight: FontWeight.w500, ), @@ -289,7 +239,7 @@ class _LoginScreenState extends State { Text( '${_selectedCountry.phoneCode} $formattedPhone', style: GoogleFonts.inter( - color: Colors.white, + color: cs.onSurface, fontSize: 18, fontWeight: FontWeight.w500, ), @@ -305,7 +255,7 @@ class _LoginScreenState extends State { child: Text( 'Изменить', style: GoogleFonts.inter( - color: const Color(0xFFAFAFFF), + color: cs.primary, fontSize: 15, fontWeight: FontWeight.w500, ), @@ -326,7 +276,7 @@ class _LoginScreenState extends State { child: Text( 'Готово', style: GoogleFonts.inter( - color: const Color(0xFFAFAFFF), + color: cs.primary, fontSize: 15, fontWeight: FontWeight.w600, ), @@ -349,8 +299,9 @@ class _LoginScreenState extends State { @override Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; return Scaffold( - backgroundColor: const Color(0xFF0D0D12), + backgroundColor: cs.surface, body: GestureDetector( behavior: HitTestBehavior.translucent, onVerticalDragEnd: (details) { @@ -376,17 +327,17 @@ class _LoginScreenState extends State { children: [ IconButton( onPressed: () {}, - icon: const Icon( + icon: Icon( Symbols.admin_panel_settings, - color: Colors.white70, + color: cs.onSurfaceVariant, weight: 400, ), ), IconButton( onPressed: () {}, - icon: const Icon( + icon: Icon( Symbols.language, - color: Colors.white70, + color: cs.onSurfaceVariant, weight: 400, ), ), @@ -399,23 +350,23 @@ class _LoginScreenState extends State { Image.asset( 'assets/komet.png', height: 80, - color: Colors.white, + color: cs.onSurface, ), const SizedBox(height: 16), Text( 'Войдите в Komet', style: GoogleFonts.inter( - color: Colors.white, + color: cs.onSurface, fontSize: 32, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 8), - const Text( + Text( 'Проверьте код страны и введите свой\nномер телефона.', textAlign: TextAlign.center, style: TextStyle( - color: Colors.white70, + color: cs.onSurfaceVariant, fontSize: 16, fontWeight: FontWeight.w400, height: 1.4, @@ -435,13 +386,13 @@ class _LoginScreenState extends State { Text( _selectedCountry.ru, style: GoogleFonts.inter( - color: Colors.white, + color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w400, ), ), const Spacer(), - const Icon(Icons.keyboard_arrow_down, color: Colors.white70), + Icon(Icons.keyboard_arrow_down, color: cs.onSurfaceVariant), ], ), ), @@ -454,7 +405,7 @@ class _LoginScreenState extends State { Text( _selectedCountry.phoneCode, style: GoogleFonts.inter( - color: Colors.white, + color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w400, ), @@ -463,7 +414,7 @@ class _LoginScreenState extends State { Container( width: 1, height: 24, - color: Colors.white24, + color: cs.outlineVariant, ), const SizedBox(width: 12), Expanded( @@ -474,10 +425,10 @@ class _LoginScreenState extends State { FilteringTextInputFormatter.digitsOnly, _PhoneInputFormatter(_selectedCountry), ], - style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w400), - decoration: const InputDecoration( + style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w400), + decoration: InputDecoration( hintText: '(000) 000-00-00', - hintStyle: TextStyle(color: Colors.white38, fontSize: 15, fontWeight: FontWeight.w400), + hintStyle: TextStyle(color: cs.outline, fontSize: 15, fontWeight: FontWeight.w400), border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, @@ -499,7 +450,7 @@ class _LoginScreenState extends State { child: Text( 'Другие способы входа', style: GoogleFonts.inter( - color: const Color(0xFFAFAFFF), + color: cs.primary, fontSize: 14, fontWeight: FontWeight.w400, height: 1.4, @@ -516,7 +467,7 @@ class _LoginScreenState extends State { child: RichText( text: TextSpan( style: GoogleFonts.inter( - color: Colors.white, + color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w500, height: 1.4, @@ -525,7 +476,7 @@ class _LoginScreenState extends State { TextSpan( text: 'Продолжая, вы соглашаетесь с \n', style: GoogleFonts.inter( - color: Colors.white, + color: cs.onSurface, fontSize: 14, height: 1.4, fontWeight: FontWeight.w400, @@ -534,7 +485,7 @@ class _LoginScreenState extends State { TextSpan( text: 'пользовательскими соглашениями', style: GoogleFonts.inter( - color: const Color(0xFFAFAFFF), + color: cs.primary, fontSize: 14, height: 1.4, fontWeight: FontWeight.w400, @@ -551,11 +502,11 @@ class _LoginScreenState extends State { padding: const EdgeInsets.only(bottom: 16.0), child: FloatingActionButton( onPressed: _isPhoneValid ? _validateAndSubmit : null, - backgroundColor: _isPhoneValid ? const Color(0xffc1c4ff) : Colors.white10, + backgroundColor: _isPhoneValid ? cs.primaryContainer : cs.surfaceContainerHighest, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50), ), - child: Icon(Icons.arrow_forward, color: _isPhoneValid ? Colors.black : Colors.white24), + child: Icon(Icons.arrow_forward, color: _isPhoneValid ? cs.onPrimaryContainer : cs.onSurfaceVariant), ), ), ], @@ -574,11 +525,11 @@ class _LoginScreenState extends State { ); } - Widget _buildInputField({ required String label, required Widget content, }) { + final cs = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -591,7 +542,7 @@ class _LoginScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( border: Border.all( - color: const Color(0xFFBEC2FF), + color: cs.primary, width: 1.5, ), borderRadius: BorderRadius.circular(50), @@ -602,12 +553,12 @@ class _LoginScreenState extends State { top: -10, left: 20, child: Container( - color: const Color(0xFF0D0D12), + color: cs.surface, padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( label, style: GoogleFonts.inter( - color: const Color(0xFFBEC2FF), + color: cs.primary, fontSize: 14, fontWeight: FontWeight.w400, ), diff --git a/lib/frontend/screens/auth/select_country_screen.dart b/lib/frontend/screens/auth/select_country_screen.dart new file mode 100644 index 0000000..a72335e --- /dev/null +++ b/lib/frontend/screens/auth/select_country_screen.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/core/config/countries.dart'; + +class SelectCountryScreen extends StatefulWidget { + final CountryName selectedCountry; + + const SelectCountryScreen({super.key, required this.selectedCountry}); + + @override + State createState() => _SelectCountryScreenState(); +} + +class _SelectCountryScreenState extends State { + bool _isSearching = false; + final TextEditingController _searchController = TextEditingController(); + List _filteredCountries = allCountries; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _filterCountries(String query) { + setState(() { + if (query.isEmpty) { + _filteredCountries = allCountries; + } else { + final q = query.toLowerCase(); + _filteredCountries = allCountries.where((c) { + return c.ru.toLowerCase().contains(q) || + c.en.toLowerCase().contains(q) || + c.phoneCode.contains(q); + }).toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + ), + title: _isSearching + ? TextField( + controller: _searchController, + autofocus: true, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w400, + ), + decoration: InputDecoration( + hintText: 'Поиск страны...', + hintStyle: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontSize: 18, + fontWeight: FontWeight.w400, + ), + border: InputBorder.none, + ), + onChanged: _filterCountries, + ) + : Text( + 'Выберите страну', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + centerTitle: true, + actions: [ + IconButton( + onPressed: () { + setState(() { + _isSearching = !_isSearching; + if (!_isSearching) { + _searchController.clear(); + _filteredCountries = allCountries; + } + }); + }, + icon: Icon( + _isSearching ? Symbols.close : Symbols.search, + color: cs.onSurface, + ), + ), + ], + ), + body: ListView.builder( + itemCount: _filteredCountries.length, + itemBuilder: (context, index) { + final country = _filteredCountries[index]; + final isSelected = country.code == widget.selectedCountry.code; + + return ListTile( + leading: Text( + country.phoneCode, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + title: Text( + country.ru, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + ), + ), + trailing: isSelected + ? Icon(Symbols.check, color: cs.primary) + : null, + onTap: () { + Navigator.pop(context, country); + }, + ); + }, + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 5358b67..4260cd8 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -15,8 +15,9 @@ class _ChatListScreenState extends State { @override Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; return Scaffold( - backgroundColor: const Color(0xFF0D0D12), + backgroundColor: cs.surface, body: SafeArea( bottom: false, child: Stack( @@ -29,10 +30,10 @@ class _ChatListScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( + Text( 'Подключение...', style: TextStyle( - color: Colors.white, + color: cs.onSurface, fontSize: 20, fontWeight: FontWeight.w600, fontFamily: 'Outfit', @@ -40,9 +41,9 @@ class _ChatListScreenState extends State { ), IconButton( onPressed: () {}, - icon: const Icon( + icon: Icon( Symbols.more_vert, - color: Colors.white54, + color: cs.outline, weight: 400, ), ), @@ -63,27 +64,26 @@ class _ChatListScreenState extends State { ), ), ), - SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), sliver: SliverToBoxAdapter( child: Container( height: 44, decoration: BoxDecoration( - color: const Color(0xFF1E1E2A), + color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(50), ), padding: const EdgeInsets.symmetric(horizontal: 16), - child: const Row( + child: Row( children: [ - Icon(Symbols.search, color: Colors.white38, size: 20, weight: 400), - SizedBox(width: 10), + Icon(Symbols.search, color: cs.outline, size: 20, weight: 400), + const SizedBox(width: 10), Expanded( child: TextField( - style: TextStyle(color: Colors.white, fontSize: 15), + style: TextStyle(color: cs.onSurface, fontSize: 15), decoration: InputDecoration( hintText: 'Поиск', - hintStyle: TextStyle(color: Colors.white38, fontSize: 15), + hintStyle: TextStyle(color: cs.outline, fontSize: 15), border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, @@ -95,7 +95,6 @@ class _ChatListScreenState extends State { ), ), ), - SliverToBoxAdapter( child: SizedBox( height: 48, @@ -187,11 +186,11 @@ class _ChatListScreenState extends State { height: 68, padding: const EdgeInsets.symmetric(horizontal: 2), decoration: BoxDecoration( - color: const Color(0xFF1E1E28), + color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(34), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.5), + color: Colors.black.withValues(alpha: 0.5), blurRadius: 20, offset: const Offset(0, 10), ), @@ -200,17 +199,17 @@ class _ChatListScreenState extends State { child: LayoutBuilder( builder: (context, constraints) { double totalWidth = constraints.maxWidth; - + double totalWeight = 5.2; double unitWidth = totalWidth / totalWeight; double activeWidth = unitWidth * 2.2; double inactiveWidth = unitWidth * 1.0; - + double leftOffset = 0; for (int i = 0; i < _currentNavIndex; i++) { leftOffset += inactiveWidth; } - + return Stack( children: [ AnimatedPositioned( @@ -222,7 +221,7 @@ class _ChatListScreenState extends State { width: activeWidth - 8, child: Container( decoration: BoxDecoration( - color: const Color(0xFFBEC2FF), + color: cs.primary, borderRadius: BorderRadius.circular(26), ), ), @@ -237,7 +236,7 @@ class _ChatListScreenState extends State { case 2: icon = Symbols.person_pin; label = 'Контакты'; break; default: icon = Symbols.settings; label = 'Настройки'; } - + bool isSelected = _currentNavIndex == index; return AnimatedContainer( duration: const Duration(milliseconds: 350), @@ -261,11 +260,11 @@ class _ChatListScreenState extends State { bottom: 110, child: FloatingActionButton( onPressed: () {}, - backgroundColor: const Color(0xFFC1C4FF), + backgroundColor: cs.primaryContainer, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), - child: const Icon(Symbols.add, color: Colors.black, size: 28, weight: 400), + child: Icon(Symbols.add, color: cs.onPrimaryContainer, size: 28, weight: 400), ), ), ], @@ -275,6 +274,7 @@ class _ChatListScreenState extends State { } Widget _buildStoryItem(String name, String imageUrl, bool hasUpdate) { + final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(right: 16), child: Column( @@ -284,8 +284,8 @@ class _ChatListScreenState extends State { decoration: BoxDecoration( shape: BoxShape.circle, border: hasUpdate - ? Border.all(color: const Color(0xFFC1C4FF), width: 2) - : Border.all(color: Colors.white10), + ? Border.all(color: cs.primary, width: 2) + : Border.all(color: cs.outlineVariant), ), child: CircleAvatar( radius: 26, @@ -295,8 +295,8 @@ class _ChatListScreenState extends State { const SizedBox(height: 6), Text( name, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: cs.onSurface, fontSize: 11, fontWeight: FontWeight.w500, ), @@ -307,19 +307,20 @@ class _ChatListScreenState extends State { } Widget _buildFolderChip(String title) { + final cs = Theme.of(context).colorScheme; bool isSelected = _selectedCategory == title; return GestureDetector( onTap: () => setState(() => _selectedCategory = title), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( - color: isSelected ? const Color(0xFFC1C4FF) : const Color(0xFF1E1E2A), + color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(10), ), child: Text( title, style: TextStyle( - color: isSelected ? Colors.black : Color(0xFFC1C4FF), + color: isSelected ? cs.onPrimaryContainer : cs.primary, fontSize: 14, fontWeight: FontWeight.w500, ), @@ -339,6 +340,7 @@ class _ChatListScreenState extends State { int unreadCount = 0, bool isMuted = false, }) { + final cs = Theme.of(context).colorScheme; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2), leading: Stack( @@ -355,9 +357,9 @@ class _ChatListScreenState extends State { width: 12, height: 12, decoration: BoxDecoration( - color: const Color(0xFFC1C4FF), + color: cs.primary, shape: BoxShape.circle, - border: Border.all(color: const Color(0xFF0D0D12), width: 2), + border: Border.all(color: cs.surface, width: 2), ), ), ), @@ -368,20 +370,20 @@ class _ChatListScreenState extends State { Expanded( child: Text( name, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600, ), ), ), if (isMuted) - const Icon(Symbols.notifications_off, color: Colors.white24, size: 14, weight: 400), + Icon(Symbols.notifications_off, color: cs.outlineVariant, size: 14, weight: 400), const SizedBox(width: 8), Text( time, - style: const TextStyle( - color: Colors.white38, + style: TextStyle( + color: cs.outline, fontSize: 12, ), ), @@ -395,7 +397,7 @@ class _ChatListScreenState extends State { child: Text( message, style: TextStyle( - color: isTyping ? const Color(0xFFC1C4FF) : Colors.white54, + color: isTyping ? cs.primary : cs.outline, fontSize: 14, fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400, ), @@ -407,20 +409,20 @@ class _ChatListScreenState extends State { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: isMuted ? Colors.white10 : const Color(0xFF1E1E2A), + color: isMuted ? cs.surfaceContainerHighest : cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(10), ), child: Text( unreadCount.toString(), style: TextStyle( - color: isMuted ? Colors.white38 : Colors.white, + color: isMuted ? cs.outline : cs.onSurface, fontSize: 11, fontWeight: FontWeight.w600, ), ), ) else if (isRead) - const Icon(Symbols.done_all, color: Color(0xFFC1C4FF), size: 16, weight: 400), + Icon(Symbols.done_all, color: cs.primary, size: 16, weight: 400), ], ), ), @@ -428,6 +430,7 @@ class _ChatListScreenState extends State { } Widget _buildNavItem(int index, IconData icon, String label) { + final cs = Theme.of(context).colorScheme; bool isSelected = _currentNavIndex == index; return GestureDetector( onTap: () => setState(() => _currentNavIndex = index), @@ -441,7 +444,7 @@ class _ChatListScreenState extends State { children: [ Icon( icon, - color: isSelected ? Colors.black : Colors.white, + color: isSelected ? cs.onPrimary : cs.onSurface, size: 20, weight: 400, fill: isSelected ? 1.0 : 0.0, @@ -459,8 +462,8 @@ class _ChatListScreenState extends State { const SizedBox(width: 4), Text( label, - style: const TextStyle( - color: Colors.black, + style: TextStyle( + color: cs.onPrimary, fontSize: 13, fontWeight: FontWeight.w600, ), diff --git a/lib/main.dart b/lib/main.dart index 0bd0220..3833514 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:komet/frontend/screens/auth/login_screen.dart'; @@ -9,19 +10,49 @@ void main() async { class MyApp extends StatelessWidget { const MyApp({super.key}); + static const _fallbackSeed = Color(0xFFC1C4FF); + + static ColorScheme _adjustScheme(ColorScheme base) { + return base.copyWith( + surface: Color.alphaBlend( + base.primary.withValues(alpha: 0.05), + const Color(0xFF0D0D14), + ), + surfaceContainerHigh: Color.alphaBlend( + base.primary.withValues(alpha: 0.08), + const Color(0xFF1A1A26), + ), + surfaceContainerHighest: Color.alphaBlend( + base.primary.withValues(alpha: 0.12), + const Color(0xFF262636), + ), + ); + } + @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Komet', - debugShowCheckedModeBanner: false, - theme: ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - textTheme: GoogleFonts.interTextTheme( - ThemeData.dark().textTheme, - ), - ), - home: const LoginScreen(), + return DynamicColorBuilder( + builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { + final baseScheme = darkDynamic ?? ColorScheme.fromSeed( + seedColor: _fallbackSeed, + brightness: Brightness.dark, + ); + + final darkScheme = _adjustScheme(baseScheme); + + return MaterialApp( + title: 'Komet', + debugShowCheckedModeBanner: false, + theme: ThemeData( + useMaterial3: true, + colorScheme: darkScheme, + textTheme: GoogleFonts.interTextTheme( + ThemeData.dark().textTheme, + ), + ), + home: const LoginScreen(), + ); + }, ); } } diff --git a/pubspec.lock b/pubspec.lock index 6a90adc..e7c44c3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c" + url: "https://pub.dev" + source: hosted + version: "1.8.1" fake_async: dependency: transitive description: @@ -155,14 +163,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 - url: "https://pub.dev" - source: hosted - version: "4.11.0" leak_tracker: dependency: transitive description: @@ -347,46 +347,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - screen_retriever: - dependency: transitive - description: - name: screen_retriever - sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_linux: - dependency: transitive - description: - name: screen_retriever_linux - sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_macos: - dependency: transitive - description: - name: screen_retriever_macos - sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_platform_interface: - dependency: transitive - description: - name: screen_retriever_platform_interface - sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_windows: - dependency: transitive - description: - name: screen_retriever_windows - sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" - url: "https://pub.dev" - source: hosted - version: "0.2.0" sky_engine: dependency: transitive description: flutter @@ -472,14 +432,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - window_manager: - dependency: "direct main" - description: - name: window_manager - sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" - url: "https://pub.dev" - source: hosted - version: "0.5.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b832e79..60d2dc8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: logger: ^2.6.2 google_fonts: ^6.2.1 material_symbols_icons: ^4.2906.0 + dynamic_color: ^1.8.1 dev_dependencies: flutter_test: From 0306820746d5c04b01b4eef42f18bf70a3de27f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Mon, 23 Mar 2026 18:44:52 +0700 Subject: [PATCH 012/323] =?UTF-8?q?=D0=92=D1=80=D0=BE=D0=B4=D0=B5=20=D1=87?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B4=D0=BE=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=B2=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20=D0=B2=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/utils/logger.dart | 2 +- lib/frontend/screens/auth/login_screen.dart | 916 +++++++++++++------- pubspec.lock | 77 +- pubspec.yaml | 1 + 4 files changed, 673 insertions(+), 323 deletions(-) diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 5fbcd7b..65ddcb5 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -1,4 +1,4 @@ -import 'package:logger/logger.dart'; +import 'package:logger/logger.dart'; //нет это не опасный вредоносный вирус логер который украдёт ваш аккаунт Browl Starz final logger = Logger( // устанавливает минимальный уровень логов. diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index fb375b4..44a8de4 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -4,9 +4,89 @@ import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/countries.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; +void _showCustomNotification(BuildContext context, String message) { + final overlay = Overlay.of(context); + final entry = OverlayEntry( + builder: (context) => _CustomNotification(message: message), + ); + overlay.insert(entry); + Future.delayed(const Duration(milliseconds: 1900), () { + entry.remove(); + }); +} + +class _CustomNotification extends StatefulWidget { + final String message; + const _CustomNotification({required this.message}); + + @override + State<_CustomNotification> createState() => _CustomNotificationState(); +} + +class _CustomNotificationState extends State<_CustomNotification> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 300), + ); + _opacity = Tween(begin: 0.0, end: 1.0).animate(_controller); + _controller.forward(); + Future.delayed(const Duration(milliseconds: 1600), () { + if (mounted) _controller.reverse(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Positioned( + bottom: 60, + left: 0, + right: 0, + child: Material( + color: Colors.transparent, + child: Center( + child: FadeTransition( + opacity: _opacity, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + ), + child: Text( + widget.message, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } +} + class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -18,20 +98,40 @@ class _LoginScreenState extends State { final TextEditingController _phoneController = TextEditingController(); late CountryName _selectedCountry; bool _isPhoneValid = false; + bool _isTOSRead = false; @override void initState() { super.initState(); _selectedCountry = countriesByCode['RU'] ?? allCountries.first; + _checkTOS(); + } + + Future _checkTOS() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + setState(() { + _isTOSRead = prefs.getBool('IsReadeTOS') ?? false; + }); + } + } + + Future _markTOSRead() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('IsReadeTOS', true); + if (mounted) { + setState(() { + _isTOSRead = true; + }); + } } void _showCountryPicker() async { final result = await Navigator.push( context, MaterialPageRoute( - builder: (context) => SelectCountryScreen( - selectedCountry: _selectedCountry, - ), + builder: (context) => + SelectCountryScreen(selectedCountry: _selectedCountry), ), ); if (result != null) { @@ -53,146 +153,232 @@ class _LoginScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), builder: (context) { - final cs = Theme.of(context).colorScheme; - return DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 1.0, - snap: true, - snapSizes: const [0.7, 1.0], - expand: false, - builder: (context, scrollController) => Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + double progress = _isTOSRead ? 1.0 : 0.0; + return StatefulBuilder( + builder: (context, setModalState) { + final cs = Theme.of(context).colorScheme; + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 1.0, + snap: true, + snapSizes: const [0.7, 1.0], + expand: false, + builder: (context, scrollController) => Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Условия использования', - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w500, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Условия использования', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: NotificationListener( + onNotification: + (ScrollUpdateNotification notification) { + if (_isTOSRead) return false; + final metrics = notification.metrics; + if (metrics.maxScrollExtent > 0) { + double newProgress = + metrics.pixels / metrics.maxScrollExtent; + newProgress = newProgress.clamp(0.0, 1.0); + if (newProgress >= 0.99 && !_isTOSRead) { + _markTOSRead(); + setModalState(() { + progress = 1.0; + }); + } else { + setModalState(() { + progress = newProgress; + }); + } + } + return false; + }, + child: ListView( + controller: scrollController, + children: [ + //У мя вопрос а тут точно номера статей верные??? + Text( + 'Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"\n\n' + '1. Статус и отношения\n' + '1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX").\n' + '1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».\n' + '1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.\n\n' + '2. Условия использования\n' + '2.1. Используя Приложение «Komet», вы:\n' + '• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)\n' + '• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;\n' + '• Принимаете на себя все риски, связанные с использованием Приложения.\n' + '2.2. Строго запрещено:\n' + '• Использовать Приложение «Komet» для распространения запрещённого контента;\n' + '• Осуществлять массовые рассылки (спам);\n' + '• Нарушать законодательство РФ и международное право;\n' + '• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».\n' + '2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.\n' + '2.4. Особенности технического взаимодействия:\n' + '• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)\n' + '• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;\n' + '• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.\n\n' + '3. Технические аспекты\n' + '3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.\n' + '3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.\n' + '3.3. Разработчики не несут ответственности за:\n' + '• Изменения в API оригинального сервиса;\n' + '• Блокировку аккаунтов пользователей;\n' + '• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».\n\n' + '4. Конфиденциальность\n' + '4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.\n' + '4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».\n' + '4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.\n\n' + '5. Ответственность и ограничения\n' + '5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.\n' + '5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.\n\n' + '6. Правовые основания\n' + '6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:\n' + '• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;\n' + '• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;\n' + '• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;\n' + '• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.\n' + '6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:\n' + '• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;\n' + '• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;\n' + '• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.\n' + '6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.\n\n' + '7. Заключительные положения\n' + '7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:\n' + '• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;\n' + '• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;\n' + '• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.\n' + '7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.\n\n' + '8. Функции безопасности и конфиденциальности\n' + '8.1. Приложение «Komet» включает инструменты защиты приватности:\n' + '• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT);\n' + '• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n' + '• Ограничение телеметрии — для минимизации передачи диагностических данных.\n' + '8.2. Данные функции:\n' + '• Направлены исключительно на защиту конфиденциальности пользователей;\n' + '• Не используются для обхода систем безопасности оригинального сервиса;\n' + '• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n' + '8.3. Разработчики не несут ответственности за:\n' + '• Блокировки, связанные с использованием инструментов конфиденциальности;\n' + '• Изменения в работе сервиса при активации данных функций.\n' + '8.4. Функции экспорта и импорта сессии\n' + '8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n' + '• Обеспечения переносимости данных между устройствами пользователя\n' + '• Резервного копирования учетных данных\n' + '• Восстановления доступа при утере устройства\n' + '8.4.2. Особенности реализации:\n' + '• Экспорт сессии осуществляется без привязки к номеру телефона\n' + '• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n' + '• Ключ шифрования известен только пользователю и не сохраняется в приложении\n' + '8.4.3. Техническая реализация экспорта сессии:\n' + '• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n' + '• Используется подмена параметров сессии для сохранения контекста аутентификации\n' + '• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n' + '• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n' + '• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n' + '• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n' + '8.4.4. Правовые основания:\n' + '• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n' + '• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n' + '• Принцип минимизации данных — сбор только необходимой для работы информации\n' + '• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n' + '• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n' + '• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n' + '8.4.5. Ограничения ответственности:\n' + '• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n' + '• Разработчики не имеют доступа к зашифрованным данным сессии\n' + '• Восстановление утерянных паролей невозможно в целях безопасности\n' + '• Ключи шифрования не хранятся в приложении и известны только пользователю', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.5, + ), + ), + const SizedBox(height: 32), + ], + ), ), ), - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + const SizedBox(height: 16), + Stack( + alignment: Alignment.centerRight, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 48, + alignment: Alignment.centerLeft, + padding: EdgeInsets.only( + right: progress == 1.0 ? 56.0 : 0.0, + ), + child: Container( + height: 4, + width: double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(2), + ), + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: progress < 1.0 ? progress : 1.0, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: progress == 1.0 ? 1.0 : 0.0, + curve: Curves.easeIn, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + transform: Matrix4.translationValues( + progress == 1.0 ? 0 : 20, + 0, + 0, + ), + width: 40, + height: 40, + decoration: BoxDecoration( + color: cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Symbols.check, + color: cs.onPrimaryContainer, + size: 24, + ), + ), + ), + ], ), ], ), - const SizedBox(height: 16), - Expanded( - child: ListView( - controller: scrollController, - children: [ - Text( - 'Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"\n\n' - '1. Статус и отношения\n' - '1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX").\n' - '1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».\n' - '1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.\n\n' - '2. Условия использования\n' - '2.1. Используя Приложение «Komet», вы:\n' - '• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)\n' - '• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;\n' - '• Принимаете на себя все риски, связанные с использованием Приложения.\n' - '2.2. Строго запрещено:\n' - '• Использовать Приложение «Komet» для распространения запрещённого контента;\n' - '• Осуществлять массовые рассылки (спам);\n' - '• Нарушать законодательство РФ и международное право;\n' - '• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».\n' - '2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.\n' - '2.4. Особенности технического взаимодействия:\n' - '• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)\n' - '• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;\n' - '• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.\n\n' - '3. Технические аспекты\n' - '3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.\n' - '3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.\n' - '3.3. Разработчики не несут ответственности за:\n' - '• Изменения в API оригинального сервиса;\n' - '• Блокировку аккаунтов пользователей;\n' - '• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».\n\n' - '4. Конфиденциальность\n' - '4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.\n' - '4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».\n' - '4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.\n\n' - '5. Ответственность и ограничения\n' - '5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.\n' - '5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.\n\n' - '6. Правовые основания\n' - '6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:\n' - '• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;\n' - '• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;\n' - '• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;\n' - '• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.\n' - '6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:\n' - '• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;\n' - '• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;\n' - '• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.\n' - '6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.\n\n' - '7. Заключительные положения\n' - '7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:\n' - '• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;\n' - '• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;\n' - '• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.\n' - '7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.\n\n' - '8. Функции безопасности и конфиденциальности\n' - '8.1. Приложение «Komet» включает инструменты защиты приватности:\n' - '• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT);\n' - '• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n' - '• Ограничение телеметрии — для минимизации передачи диагностических данных.\n' - '8.2. Данные функции:\n' - '• Направлены исключительно на защиту конфиденциальности пользователей;\n' - '• Не используются для обхода систем безопасности оригинального сервиса;\n' - '• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n' - '8.3. Разработчики не несут ответственности за:\n' - '• Блокировки, связанные с использованием инструментов конфиденциальности;\n' - '• Изменения в работе сервиса при активации данных функций.\n' - '8.4. Функции экспорта и импорта сессии\n' - '8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n' - '• Обеспечения переносимости данных между устройствами пользователя\n' - '• Резервного копирования учетных данных\n' - '• Восстановления доступа при утере устройства\n' - '8.4.2. Особенности реализации:\n' - '• Экспорт сессии осуществляется без привязки к номеру телефона\n' - '• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n' - '• Ключ шифрования известен только пользователю и не сохраняется в приложении\n' - '8.4.3. Техническая реализация экспорта сессии:\n' - '• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n' - '• Используется подмена параметров сессии для сохранения контекста аутентификации\n' - '• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n' - '• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n' - '• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n' - '• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n' - '8.4.4. Правовые основания:\n' - '• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n' - '• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n' - '• Принцип минимизации данных — сбор только необходимой для работы информации\n' - '• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n' - '• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n' - '• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n' - '8.4.5. Ограничения ответственности:\n' - '• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n' - '• Разработчики не имеют доступа к зашифрованным данным сессии\n' - '• Восстановление утерянных паролей невозможно в целях безопасности\n' - '• Ключи шифрования не хранятся в приложении и известны только пользователю', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1.5, - ), - ), - ], - ), - ), - ], - ), - ), + ), + ); + }, ); }, ); @@ -268,7 +454,8 @@ class _LoginScreenState extends State { context, MaterialPageRoute( builder: (context) => CodeConfirmationScreen( - phoneNumber: '${_selectedCountry.phoneCode} $formattedPhone', + phoneNumber: + '${_selectedCountry.phoneCode} $formattedPhone', ), ), ); @@ -294,9 +481,81 @@ class _LoginScreenState extends State { } void _validateAndSubmit() { + if (!_isTOSRead) { + _showCustomNotification(context, 'Соглащение прочитай щегол'); + return; + } _showPhoneConfirmationDialog(_phoneController.text); } + void _showOtherLoginMethods(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 24.0, + horizontal: 16.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon(Symbols.qr_code_2, color: cs.onSurface), + title: Text( + 'По QR code', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Symbols.key, color: cs.onSurface), + title: Text( + 'По токену', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Symbols.description, color: cs.onSurface), + title: Text( + 'По файлу сессии', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -305,7 +564,8 @@ class _LoginScreenState extends State { body: GestureDetector( behavior: HitTestBehavior.translucent, onVerticalDragEnd: (details) { - if (details.primaryVelocity != null && details.primaryVelocity! < -500) { + if (details.primaryVelocity != null && + details.primaryVelocity! < -500) { _showTOS(context); } }, @@ -314,199 +574,228 @@ class _LoginScreenState extends State { builder: (context, constraints) { return SingleChildScrollView( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 44), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - onPressed: () {}, - icon: Icon( - Symbols.admin_panel_settings, - color: cs.onSurfaceVariant, - weight: 400, - ), - ), - IconButton( - onPressed: () {}, - icon: Icon( - Symbols.language, - color: cs.onSurfaceVariant, - weight: 400, - ), - ), - ], - ), - const SizedBox(height: 74), - Center( - child: Column( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 44), + Row( + mainAxisAlignment: MainAxisAlignment.end, children: [ - Image.asset( - 'assets/komet.png', - height: 80, - color: cs.onSurface, - ), - const SizedBox(height: 16), - Text( - 'Войдите в Komet', - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 32, - fontWeight: FontWeight.w500, + IconButton( + onPressed: () {}, + icon: Icon( + Symbols.admin_panel_settings, + color: cs.onSurfaceVariant, + weight: 400, ), ), - const SizedBox(height: 8), - Text( - 'Проверьте код страны и введите свой\nномер телефона.', - textAlign: TextAlign.center, - style: TextStyle( + IconButton( + onPressed: () {}, + icon: Icon( + Symbols.language, color: cs.onSurfaceVariant, - fontSize: 16, - fontWeight: FontWeight.w400, - height: 1.4, + weight: 400, ), ), ], ), - ), - const SizedBox(height: 48), - InkWell( - onTap: _showCountryPicker, - borderRadius: BorderRadius.circular(50), - child: _buildInputField( - label: 'Страна', + const SizedBox(height: 74), + Center( + child: Column( + children: [ + Image.asset( + 'assets/komet.png', + height: 80, + color: cs.onSurface, + ), + const SizedBox(height: 16), + Text( + 'Войдите в Komet', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 32, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Проверьте код страны и введите свой\nномер телефона.', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w400, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 48), + InkWell( + onTap: _showCountryPicker, + borderRadius: BorderRadius.circular(50), + child: _buildInputField( + label: 'Страна', + content: Row( + children: [ + Text( + _selectedCountry.ru, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + ), + const Spacer(), + Icon( + Icons.keyboard_arrow_down, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + ), + const SizedBox(height: 40), + _buildInputField( + label: 'Номер телефона', content: Row( children: [ Text( - _selectedCountry.ru, + _selectedCountry.phoneCode, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w400, ), ), - const Spacer(), - Icon(Icons.keyboard_arrow_down, color: cs.onSurfaceVariant), + const SizedBox(width: 12), + Container( + width: 1, + height: 24, + color: cs.outlineVariant, + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + _PhoneInputFormatter(_selectedCountry), + ], + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + decoration: InputDecoration( + hintText: '(000) 000-00-00', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + fontWeight: FontWeight.w400, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (value) { + final digits = value.replaceAll( + RegExp(r'\D'), + '', + ); + setState(() { + _isPhoneValid = + digits.length == + _selectedCountry.phoneDigits; + }); + }, + ), + ), ], ), ), - ), - const SizedBox(height: 40), - _buildInputField( - label: 'Номер телефона', - content: Row( - children: [ - Text( - _selectedCountry.phoneCode, - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(width: 12), - Container( - width: 1, - height: 24, - color: cs.outlineVariant, - ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _phoneController, - keyboardType: TextInputType.phone, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - _PhoneInputFormatter(_selectedCountry), - ], - style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w400), - decoration: InputDecoration( - hintText: '(000) 000-00-00', - hintStyle: TextStyle(color: cs.outline, fontSize: 15, fontWeight: FontWeight.w400), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - onChanged: (value) { - final digits = value.replaceAll(RegExp(r'\D'), ''); - setState(() { - _isPhoneValid = digits.length == _selectedCountry.phoneDigits; - }); - }, - ), - ), - ], + const SizedBox(height: 24), + TextButton( + onPressed: () => _showOtherLoginMethods(context), + child: Text( + 'Другие способы входа', + style: GoogleFonts.inter( + color: cs.primary, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.4, ), ), - const SizedBox(height: 24), - TextButton( - onPressed: () {}, - child: Text( - 'Другие способы входа', - style: GoogleFonts.inter( - color: cs.primary, - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1.4, - ), ), - ), - const Spacer(), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(bottom: 24.0), - child: RichText( - text: TextSpan( - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 14, - fontWeight: FontWeight.w500, - height: 1.4, + const Spacer(), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: RichText( + text: TextSpan( + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.4, + ), + children: [ + TextSpan( + text: + 'Продолжая, вы соглашаетесь с \n', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), + TextSpan( + text: + 'пользовательскими соглашениями', + style: GoogleFonts.inter( + color: cs.primary, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => _showTOS(context), + ), + ], ), - children: [ - TextSpan( - text: 'Продолжая, вы соглашаетесь с \n', - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), - TextSpan( - text: 'пользовательскими соглашениями', - style: GoogleFonts.inter( - color: cs.primary, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => _showTOS(context), - ), - ], ), ), ), - ), Padding( padding: const EdgeInsets.only(bottom: 16.0), child: FloatingActionButton( - onPressed: _isPhoneValid ? _validateAndSubmit : null, - backgroundColor: _isPhoneValid ? cs.primaryContainer : cs.surfaceContainerHighest, + onPressed: _isPhoneValid + ? _validateAndSubmit + : null, + backgroundColor: _isPhoneValid + ? cs.primaryContainer + : cs.surfaceContainerHighest, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50), ), - child: Icon(Icons.arrow_forward, color: _isPhoneValid ? cs.onPrimaryContainer : cs.onSurfaceVariant), + child: Icon( + Icons.arrow_forward, + color: _isPhoneValid + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), ), ), ], @@ -525,10 +814,7 @@ class _LoginScreenState extends State { ); } - Widget _buildInputField({ - required String label, - required Widget content, - }) { + Widget _buildInputField({required String label, required Widget content}) { final cs = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -541,10 +827,7 @@ class _LoginScreenState extends State { height: 54, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( - border: Border.all( - color: cs.primary, - width: 1.5, - ), + border: Border.all(color: cs.primary, width: 1.5), borderRadius: BorderRadius.circular(50), ), child: content, @@ -578,7 +861,9 @@ class _PhoneInputFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( - TextEditingValue oldValue, TextEditingValue newValue) { + TextEditingValue oldValue, + TextEditingValue newValue, + ) { var text = newValue.text.replaceAll(RegExp(r'\D'), ''); if (newValue.text.length < oldValue.text.length) { @@ -602,19 +887,22 @@ class _PhoneInputFormatter extends TextInputFormatter { final groupSize = country.phoneGroupSizes[i]; final remainingDigits = text.length - digitIdx; - final digitsToTake = remainingDigits < groupSize ? remainingDigits : groupSize; + final digitsToTake = remainingDigits < groupSize + ? remainingDigits + : groupSize; buffer.write(text.substring(digitIdx, digitIdx + digitsToTake)); digitIdx += digitsToTake; - if (digitIdx == text.length && i < country.phoneGroupSeparators.length - 1) { - } + if (digitIdx == text.length && + i < country.phoneGroupSeparators.length - 1) {} } if (digitIdx == text.length && text.length == country.phoneDigits) { - if (country.phoneGroupSeparators.length > country.phoneGroupSizes.length) { - buffer.write(country.phoneGroupSeparators.last); - } + if (country.phoneGroupSeparators.length > + country.phoneGroupSizes.length) { + buffer.write(country.phoneGroupSeparators.last); + } } final formattedText = buffer.toString(); diff --git a/pubspec.lock b/pubspec.lock index e7c44c3..bee7d68 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -123,6 +123,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -215,18 +220,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -347,6 +352,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -396,10 +457,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 60d2dc8..3498a9e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: google_fonts: ^6.2.1 material_symbols_icons: ^4.2906.0 dynamic_color: ^1.8.1 + shared_preferences: ^2.5.4 dev_dependencies: flutter_test: From 71e776b09962abc727abe0ad494c5390944dc73f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Thu, 26 Mar 2026 12:06:42 +0700 Subject: [PATCH 013/323] =?UTF-8?q?=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=B5=D0=BA=20=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D1=8B=D0=BA=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/spoof_data.dart | 31 + lib/frontend/screens/auth/login_screen.dart | 145 ++-- .../screens/auth/spoff_redacted_screen.dart | 217 ++++++ lib/frontend/screens/calls/calls_tab.dart | 21 + .../screens/chats/chat_list_screen.dart | 706 ++++++++++++------ .../screens/contacts/contacts_tab.dart | 21 + .../screens/profile/settings_tab.dart | 21 + lib/frontend/widgets/custom_notification.dart | 81 ++ 8 files changed, 927 insertions(+), 316 deletions(-) create mode 100644 lib/core/config/spoof_data.dart create mode 100644 lib/frontend/screens/auth/spoff_redacted_screen.dart create mode 100644 lib/frontend/screens/calls/calls_tab.dart create mode 100644 lib/frontend/screens/contacts/contacts_tab.dart create mode 100644 lib/frontend/screens/profile/settings_tab.dart create mode 100644 lib/frontend/widgets/custom_notification.dart diff --git a/lib/core/config/spoof_data.dart b/lib/core/config/spoof_data.dart new file mode 100644 index 0000000..f60c3ff --- /dev/null +++ b/lib/core/config/spoof_data.dart @@ -0,0 +1,31 @@ +class SpoofData { + static const List deviceNames = [ + 'Samsung Galaxy S23', + 'Xiaomi 13 Pro', + 'Google Pixel 7', + 'OnePlus 11', + ]; + + static const List osVersions = ['12', '13', '14']; + + static const List resolutions = [ + '1080x2400', + '1440x3200', + '720x1600', + ]; + + static const List deviceIds = [ + 'a1b2c3d4e5f6', + 'f8e7d6c5b4a3', + '9876543210ab', + '1234567890cd', + ]; + + static const List architectures = ['arm64-v8a', 'armeabi-v7a']; + + static const String deviceType = 'android'; + static const String timezone = 'Europe/Moscow'; + static const String locale = 'ru_RU'; + static const String appVersion = '26.10.1'; + static const String buildNumber = '6728'; +} diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 44a8de4..7a705d7 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -7,85 +7,8 @@ import 'package:komet/core/config/countries.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; - -void _showCustomNotification(BuildContext context, String message) { - final overlay = Overlay.of(context); - final entry = OverlayEntry( - builder: (context) => _CustomNotification(message: message), - ); - overlay.insert(entry); - Future.delayed(const Duration(milliseconds: 1900), () { - entry.remove(); - }); -} - -class _CustomNotification extends StatefulWidget { - final String message; - const _CustomNotification({required this.message}); - - @override - State<_CustomNotification> createState() => _CustomNotificationState(); -} - -class _CustomNotificationState extends State<_CustomNotification> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _opacity; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 300), - reverseDuration: const Duration(milliseconds: 300), - ); - _opacity = Tween(begin: 0.0, end: 1.0).animate(_controller); - _controller.forward(); - Future.delayed(const Duration(milliseconds: 1600), () { - if (mounted) _controller.reverse(); - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Positioned( - bottom: 60, - left: 0, - right: 0, - child: Material( - color: Colors.transparent, - child: Center( - child: FadeTransition( - opacity: _opacity, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), - ), - child: Text( - widget.message, - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ), - ), - ); - } -} +import 'spoff_redacted_screen.dart'; +import '../../widgets/custom_notification.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -482,12 +405,72 @@ class _LoginScreenState extends State { void _validateAndSubmit() { if (!_isTOSRead) { - _showCustomNotification(context, 'Соглащение прочитай щегол'); + showCustomNotification(context, 'Соглащение прочитай щегол'); return; } _showPhoneConfirmationDialog(_phoneController.text); } + void _showSecurityOptions(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 24.0, + horizontal: 16.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon(Symbols.security, color: cs.onSurface), + title: Text( + 'Подделка спуфа', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SpoffRedactedScreen(), + ), + ); + }, + ), + ListTile( + leading: Icon(Symbols.vpn_lock, color: cs.onSurface), + title: Text( + 'Прокси', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + }, + ), + ], + ), + ), + ); + }, + ); + } + void _showOtherLoginMethods(BuildContext context) { final cs = Theme.of(context).colorScheme; showModalBottomSheet( @@ -588,7 +571,7 @@ class _LoginScreenState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( - onPressed: () {}, + onPressed: () => _showSecurityOptions(context), icon: Icon( Symbols.admin_panel_settings, color: cs.onSurfaceVariant, diff --git a/lib/frontend/screens/auth/spoff_redacted_screen.dart b/lib/frontend/screens/auth/spoff_redacted_screen.dart new file mode 100644 index 0000000..a553a59 --- /dev/null +++ b/lib/frontend/screens/auth/spoff_redacted_screen.dart @@ -0,0 +1,217 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:komet/core/config/spoof_data.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; + +class SpoffRedactedScreen extends StatefulWidget { + const SpoffRedactedScreen({super.key}); + + @override + State createState() => _SpoffRedactedScreenState(); +} + +class _SpoffRedactedScreenState extends State { + final TextEditingController _deviceNameController = TextEditingController(); + final TextEditingController _osVersionController = TextEditingController(); + final TextEditingController _resolutionController = TextEditingController(); + final TextEditingController _deviceIdController = TextEditingController(); + final TextEditingController _architectureController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + + final random = Random(); + + setState(() { + _deviceNameController.text = + prefs.getString('spoof_device_name') ?? + SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; + _osVersionController.text = + prefs.getString('spoof_os_version') ?? + SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]; + _resolutionController.text = + prefs.getString('spoof_resolution') ?? + SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; + _deviceIdController.text = + prefs.getString('spoof_device_id') ?? + SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; + _architectureController.text = + prefs.getString('spoof_architecture') ?? + SpoofData.architectures[random.nextInt( + SpoofData.architectures.length, + )]; + }); + } + + Future _saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('spoof_device_name', _deviceNameController.text); + await prefs.setString('spoof_os_version', _osVersionController.text); + await prefs.setString('spoof_resolution', _resolutionController.text); + await prefs.setString('spoof_device_id', _deviceIdController.text); + await prefs.setString('spoof_architecture', _architectureController.text); + + if (mounted) { + showCustomNotification(context, 'Настройки сохранены'); + } + } + + @override + void dispose() { + _deviceNameController.dispose(); + _osVersionController.dispose(); + _resolutionController.dispose(); + _deviceIdController.dispose(); + _architectureController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + iconTheme: IconThemeData(color: cs.onSurface), + title: Text( + 'Подделка спуфа', + style: GoogleFonts.inter( + color: cs.onSurface, + fontWeight: FontWeight.w500, + ), + ), + backgroundColor: cs.surface, + actions: [ + IconButton( + icon: Icon(Icons.check, color: cs.primary), + onPressed: _saveSettings, + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + _buildTextField( + controller: TextEditingController(text: SpoofData.deviceType), + label: 'Тип устройства', + cs: cs, + readOnly: true, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _deviceNameController, + label: 'Имя устройства', + cs: cs, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _osVersionController, + label: 'Версия ОС', + cs: cs, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _resolutionController, + label: 'Разрешение экрана', + cs: cs, + ), + const SizedBox(height: 16), + _buildTextField( + controller: TextEditingController(text: SpoofData.timezone), + label: 'Часовой пояс', + cs: cs, + readOnly: true, + ), + const SizedBox(height: 16), + _buildTextField( + controller: TextEditingController(text: SpoofData.locale), + label: 'Локаль', + cs: cs, + readOnly: true, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _deviceIdController, + label: 'ID устройства', + cs: cs, + ), + const SizedBox(height: 16), + _buildTextField( + controller: TextEditingController(text: SpoofData.appVersion), + label: 'Версия приложения', + cs: cs, + readOnly: true, + ), + const SizedBox(height: 16), + _buildTextField( + controller: TextEditingController(text: SpoofData.buildNumber), + label: 'Build Number', + cs: cs, + readOnly: true, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _architectureController, + label: 'Архитектура', + cs: cs, + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required ColorScheme cs, + bool readOnly = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + readOnly: readOnly, + style: GoogleFonts.inter( + color: readOnly ? cs.onSurfaceVariant : cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + filled: true, + fillColor: readOnly + ? cs.surfaceContainerHighest + : cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart new file mode 100644 index 0000000..21bf27b --- /dev/null +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class CallsTab extends StatelessWidget { + const CallsTab({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Text( + 'Звонки (Заглушка)', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 4260cd8..06326fc 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'dart:math'; import 'dart:ui' as ui; +import '../calls/calls_tab.dart'; +import '../contacts/contacts_tab.dart'; +import '../profile/settings_tab.dart'; + class ChatListScreen extends StatefulWidget { const ChatListScreen({super.key}); @@ -9,9 +14,38 @@ class ChatListScreen extends StatefulWidget { State createState() => _ChatListScreenState(); } -class _ChatListScreenState extends State { +class _ChatListScreenState extends State + with SingleTickerProviderStateMixin { String _selectedCategory = 'Все чаты'; int _currentNavIndex = 0; + bool _isFabOpen = false; + late AnimationController _fabController; + + @override + void initState() { + super.initState(); + _fabController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); + } + + @override + void dispose() { + _fabController.dispose(); + super.dispose(); + } + + void _toggleFab() { + setState(() { + _isFabOpen = !_isFabOpen; + if (_isFabOpen) { + _fabController.forward(); + } else { + _fabController.reverse(); + } + }); + } @override Widget build(BuildContext context) { @@ -22,250 +56,375 @@ class _ChatListScreenState extends State { bottom: false, child: Stack( children: [ - CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - sliver: SliverToBoxAdapter( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Подключение...', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - IconButton( - onPressed: () {}, - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - ), - ], - ), - ), - ), - SliverToBoxAdapter( - child: SizedBox( - height: 96, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 20), - children: [ - _buildStoryItem('Даша', 'https://i.pravatar.cc/150?u=dasha', true), - _buildStoryItem('Мастика', 'https://i.pravatar.cc/150?u=mastika', false), - ], - ), - ), - ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - sliver: SliverToBoxAdapter( - child: Container( - height: 44, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Icon(Symbols.search, color: cs.outline, size: 20, weight: 400), - const SizedBox(width: 10), - Expanded( - child: TextField( - style: TextStyle(color: cs.onSurface, fontSize: 15), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle(color: cs.outline, fontSize: 15), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, + IndexedStack( + index: _currentNavIndex, + children: [ + CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', ), ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, + ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Кнопка 1', + Symbols.settings, + ), + _buildPopupMenuItem( + 2, + 'Кнопка 2', + Symbols.notifications, + ), + _buildPopupMenuItem( + 3, + 'Кнопка 3', + Symbols.shield, + ), + _buildPopupMenuItem( + 4, + 'Кнопка 4', + Symbols.info, + ), + ], + onSelected: (value) { + // Action handler + }, + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: SizedBox( + height: 96, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + _buildStoryItem( + 'Даша', + 'https://i.pravatar.cc/150?u=dasha', + true, + ), + _buildStoryItem( + 'Мастика', + 'https://i.pravatar.cc/150?u=mastika', + false, + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + sliver: SliverToBoxAdapter( + child: Container( + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), ), - ], + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), ), ), - ), - ), - SliverToBoxAdapter( - child: SizedBox( - height: 48, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - ui.PointerDeviceKind.touch, - ui.PointerDeviceKind.mouse, - ui.PointerDeviceKind.trackpad, - }, - ), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - physics: const BouncingScrollPhysics(), - children: [ - _buildFolderChip('Все чаты'), - const SizedBox(width: 8), - _buildFolderChip('Контакты'), - const SizedBox(width: 8), - _buildFolderChip('Пидоры'), - const SizedBox(width: 8), - _buildFolderChip('Каналы'), - const SizedBox(width: 8), - _buildFolderChip('Группы'), - const SizedBox(width: 8), - _buildFolderChip('Боты'), - const SizedBox(width: 8), - _buildFolderChip('Избранное'), - const SizedBox(width: 8), - _buildFolderChip('Архив'), - ], + SliverToBoxAdapter( + child: SizedBox( + height: 48, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + ui.PointerDeviceKind.touch, + ui.PointerDeviceKind.mouse, + ui.PointerDeviceKind.trackpad, + }, + ), + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + physics: const BouncingScrollPhysics(), + children: [ + _buildFolderChip('Все чаты'), + const SizedBox(width: 8), + _buildFolderChip('Контакты'), + const SizedBox(width: 8), + _buildFolderChip('Пидоры'), + const SizedBox(width: 8), + _buildFolderChip('Каналы'), + const SizedBox(width: 8), + _buildFolderChip('Группы'), + const SizedBox(width: 8), + _buildFolderChip('Боты'), + const SizedBox(width: 8), + _buildFolderChip('Избранное'), + const SizedBox(width: 8), + _buildFolderChip('Архив'), + ], + ), + ), ), ), - ), - ), - SliverList( - delegate: SliverChildListDelegate([ - _buildChatItem( - 'Станислав', - 'Хорошо', - '10:07', - 'https://i.pravatar.cc/150?u=stas', - isOnline: true, - isRead: true, + SliverList( + delegate: SliverChildListDelegate([ + _buildChatItem( + 'Станислав', + 'Хорошо', + '10:07', + 'https://i.pravatar.cc/150?u=stas', + isOnline: true, + isRead: true, + ), + _buildChatItem( + 'Илья', + 'печатает...', + '10:07', + 'https://i.pravatar.cc/150?u=ilya', + isOnline: true, + isTyping: true, + unreadCount: 1, + ), + _buildChatItem( + 'Вероника', + 'Спасибо', + '09:56', + 'https://i.pravatar.cc/150?u=veronika', + isRead: true, + ), + _buildChatItem( + 'Komet Client', + 'Кстати. Смотрите, какую шту...', + '09:56', + 'https://i.pravatar.cc/150?u=komet', + unreadCount: 5, + isMuted: true, + ), + _buildChatItem( + '4-й подъезд', + 'Людмила: Сколько?', + '09:34', + 'https://i.pravatar.cc/150?u=podezd', + unreadCount: 78, + isMuted: true, + ), + const SizedBox(height: 100), + ]), ), - _buildChatItem( - 'Илья', - 'печатает...', - '10:07', - 'https://i.pravatar.cc/150?u=ilya', - isOnline: true, - isTyping: true, - unreadCount: 1, - ), - _buildChatItem( - 'Вероника', - 'Спасибо', - '09:56', - 'https://i.pravatar.cc/150?u=veronika', - isRead: true, - ), - _buildChatItem( - 'Komet Client', - 'Кстати. Смотрите, какую шту...', - '09:56', - 'https://i.pravatar.cc/150?u=komet', - unreadCount: 5, - isMuted: true, - ), - _buildChatItem( - '4-й подъезд', - 'Людмила: Сколько?', - '09:34', - 'https://i.pravatar.cc/150?u=podezd', - unreadCount: 78, - isMuted: true, - ), - const SizedBox(height: 100), - ]), + ], ), + const CallsTab(), + const ContactsTab(), + const SettingsTab(), ], ), Positioned( left: 8, right: 8, - bottom: 24, - child: Container( - height: 68, - padding: const EdgeInsets.symmetric(horizontal: 2), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(34), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: LayoutBuilder( - builder: (context, constraints) { - double totalWidth = constraints.maxWidth; + bottom: 24.0, + child: RepaintBoundary( + child: Container( + height: 68, + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(34), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: LayoutBuilder( + builder: (context, constraints) { + double totalWidth = constraints.maxWidth; - double totalWeight = 5.2; - double unitWidth = totalWidth / totalWeight; - double activeWidth = unitWidth * 2.2; - double inactiveWidth = unitWidth * 1.0; + double totalWeight = 5.2; + double unitWidth = totalWidth / totalWeight; + double activeWidth = unitWidth * 2.2; + double inactiveWidth = unitWidth * 1.0; - double leftOffset = 0; - for (int i = 0; i < _currentNavIndex; i++) { - leftOffset += inactiveWidth; - } + double leftOffset = 0; + for (int i = 0; i < _currentNavIndex; i++) { + leftOffset += inactiveWidth; + } - return Stack( - children: [ - AnimatedPositioned( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - left: leftOffset + 4, - top: 8, - bottom: 8, - width: activeWidth - 8, - child: Container( - decoration: BoxDecoration( - color: cs.primary, - borderRadius: BorderRadius.circular(26), + return Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + left: leftOffset + 4, + top: 8, + bottom: 8, + width: activeWidth - 8, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(26), + ), ), ), - ), - Row( - children: List.generate(4, (index) { - IconData icon; - String label; - switch (index) { - case 0: icon = Symbols.chat_bubble; label = 'Чаты'; break; - case 1: icon = Symbols.call; label = 'Звонки'; break; - case 2: icon = Symbols.person_pin; label = 'Контакты'; break; - default: icon = Symbols.settings; label = 'Настройки'; - } + Row( + children: List.generate(4, (index) { + IconData icon; + String label; + switch (index) { + case 0: + icon = Symbols.chat_bubble; + label = 'Чаты'; + break; + case 1: + icon = Symbols.call; + label = 'Звонки'; + break; + case 2: + icon = Symbols.person_pin; + label = 'Контакты'; + break; + default: + icon = Symbols.settings; + label = 'Настройки'; + } - bool isSelected = _currentNavIndex == index; - return AnimatedContainer( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - width: isSelected ? (activeWidth - 0.5) : (inactiveWidth - 0.5), - child: ClipRRect( - borderRadius: BorderRadius.circular(26), - child: _buildNavItem(index, icon, label), - ), - ); - }), - ), - ], - ); - }, + bool isSelected = _currentNavIndex == index; + return AnimatedContainer( + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + width: isSelected + ? (activeWidth - 0.5) + : (inactiveWidth - 0.5), + child: ClipRRect( + borderRadius: BorderRadius.circular(26), + child: _buildNavItem(index, icon, label), + ), + ); + }), + ), + ], + ); + }, + ), ), ), ), - Positioned( - right: 20, - bottom: 110, - child: FloatingActionButton( - onPressed: () {}, - backgroundColor: cs.primaryContainer, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Icon(Symbols.add, color: cs.onPrimaryContainer, size: 28, weight: 400), - ), + AnimatedBuilder( + animation: _fabController, + builder: (context, child) { + final double val = Curves.easeOutCubic.transform( + _fabController.value, + ); + return Stack( + clipBehavior: Clip.none, + children: [ + if (_fabController.value > 0) + Positioned.fill( + child: GestureDetector( + onTap: _toggleFab, + behavior: HitTestBehavior.opaque, + child: Container( + color: Colors.black.withValues(alpha: val * 0.2), + ), + ), + ), + if (_fabController.value > 0) + Positioned( + right: 20, + bottom: 110 + 74, + child: RepaintBoundary( + child: Transform.scale( + scale: val, + alignment: Alignment.bottomRight, + child: Opacity( + opacity: val > 0.5 ? (val - 0.5) * 2 : 0, + child: _buildFabMenu(), + ), + ), + ), + ), + Positioned( + right: 20, + bottom: 110, + child: FloatingActionButton( + onPressed: _toggleFab, + backgroundColor: cs.primaryContainer, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Transform.rotate( + angle: val * (pi / 4), + child: Icon( + Symbols.add, + color: cs.onPrimaryContainer, + size: 28, + weight: 400, + ), + ), + ), + ), + ], + ); + }, ), ], ), @@ -345,10 +504,7 @@ class _ChatListScreenState extends State { contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2), leading: Stack( children: [ - CircleAvatar( - radius: 24, - backgroundImage: NetworkImage(imageUrl), - ), + CircleAvatar(radius: 24, backgroundImage: NetworkImage(imageUrl)), if (isOnline) Positioned( right: 0, @@ -378,15 +534,14 @@ class _ChatListScreenState extends State { ), ), if (isMuted) - Icon(Symbols.notifications_off, color: cs.outlineVariant, size: 14, weight: 400), - const SizedBox(width: 8), - Text( - time, - style: TextStyle( - color: cs.outline, - fontSize: 12, + Icon( + Symbols.notifications_off, + color: cs.outlineVariant, + size: 14, + weight: 400, ), - ), + const SizedBox(width: 8), + Text(time, style: TextStyle(color: cs.outline, fontSize: 12)), ], ), subtitle: Padding( @@ -409,7 +564,9 @@ class _ChatListScreenState extends State { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: isMuted ? cs.surfaceContainerHighest : cs.surfaceContainerHigh, + color: isMuted + ? cs.surfaceContainerHighest + : cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(10), ), child: Text( @@ -478,4 +635,83 @@ class _ChatListScreenState extends State { ), ); } + + Widget _buildFabMenu() { + final cs = Theme.of(context).colorScheme; + return Container( + width: 220, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFabMenuItem(Symbols.search, 'Найти по номеру'), + _buildFabMenuItem(Symbols.group_add, 'Добавить группу'), + _buildFabMenuItem(Symbols.campaign, 'Создать канал'), + ], + ), + ); + } + + Widget _buildFabMenuItem(IconData icon, String title) { + final cs = Theme.of(context).colorScheme; + return InkWell( + onTap: () { + // Action logic here + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 22), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + PopupMenuItem _buildPopupMenuItem( + int value, + String title, + IconData icon, + ) { + final cs = Theme.of(context).colorScheme; + return PopupMenuItem( + value: value, + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 20), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } } diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart new file mode 100644 index 0000000..c47b562 --- /dev/null +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class ContactsTab extends StatelessWidget { + const ContactsTab({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Text( + 'Контакты (Заглушка)', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart new file mode 100644 index 0000000..2a60054 --- /dev/null +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class SettingsTab extends StatelessWidget { + const SettingsTab({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Text( + 'Настройки (Заглушка)', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/custom_notification.dart b/lib/frontend/widgets/custom_notification.dart new file mode 100644 index 0000000..85c6e91 --- /dev/null +++ b/lib/frontend/widgets/custom_notification.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +void showCustomNotification(BuildContext context, String message) { + final overlay = Overlay.of(context); + final entry = OverlayEntry( + builder: (context) => CustomNotification(message: message), + ); + overlay.insert(entry); + Future.delayed(const Duration(milliseconds: 1900), () { + entry.remove(); + }); +} + +class CustomNotification extends StatefulWidget { + final String message; + const CustomNotification({required this.message, super.key}); + + @override + State createState() => _CustomNotificationState(); +} + +class _CustomNotificationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 300), + ); + _opacity = Tween(begin: 0.0, end: 1.0).animate(_controller); + _controller.forward(); + Future.delayed(const Duration(milliseconds: 1600), () { + if (mounted) _controller.reverse(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Positioned( + bottom: 60, + left: 0, + right: 0, + child: Material( + color: Colors.transparent, + child: Center( + child: FadeTransition( + opacity: _opacity, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + ), + child: Text( + widget.message, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } +} From f9f2efedf4106ee40d178366c6e1d8b2b220c5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Thu, 26 Mar 2026 18:05:30 +0700 Subject: [PATCH 014/323] =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 872 ++++++++++++------ lib/frontend/screens/chats/chat_screen.dart | 240 +++++ 2 files changed, 810 insertions(+), 302 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 06326fc..678ac56 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'dart:math'; import 'dart:ui' as ui; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'chat_screen.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -20,6 +23,27 @@ class _ChatListScreenState extends State int _currentNavIndex = 0; bool _isFabOpen = false; late AnimationController _fabController; + final Set _selectedChats = {}; + final ScrollController _scrollController = ScrollController(); + double _pullRatio = 0.0; // 0.0 = folded (hidden row), 1.0 = fully expanded + + bool get _isSelectionMode => _selectedChats.isNotEmpty; + + void _toggleSelection(String chatId) { + setState(() { + if (_selectedChats.contains(chatId)) { + _selectedChats.remove(chatId); + } else { + _selectedChats.add(chatId); + } + }); + } + + void _clearSelection() { + setState(() { + _selectedChats.clear(); + }); + } @override void initState() { @@ -28,11 +52,31 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + if (_scrollController.hasClients) { + final double offset = _scrollController.offset; + if (offset < 0) { + final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); + if (newRatio != _pullRatio) { + setState(() { + _pullRatio = newRatio; + }); + } + } else if (_pullRatio > 0) { + setState(() { + _pullRatio = 0.0; + }); + } + } } @override void dispose() { _fabController.dispose(); + _scrollController.dispose(); super.dispose(); } @@ -59,226 +103,328 @@ class _ChatListScreenState extends State IndexedStack( index: _currentNavIndex, children: [ - CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - sliver: SliverToBoxAdapter( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Подключение...', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), + Listener( + onPointerSignal: (pointerSignal) { + if (pointerSignal is PointerScrollEvent) { + if (_scrollController.hasClients && + _scrollController.offset <= 0) { + if (pointerSignal.scrollDelta.dy < 0) { + // Scrolled UP (pulling down) + setState(() { + _pullRatio = (_pullRatio + 0.2).clamp(0.0, 1.0); + }); + } else if (pointerSignal.scrollDelta.dy > 0 && + _pullRatio > 0) { + // Scrolled DOWN (folding up) + setState(() { + _pullRatio = (_pullRatio - 0.2).clamp(0.0, 1.0); + }); + } + } + } + }, + child: CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverToBoxAdapter( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + height: _isSelectionMode + ? 0 + : (132 + (96 * _pullRatio)), + color: Colors.transparent, + clipBehavior: Clip.hardEdge, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + transform: Matrix4.translationValues( + 0, + _isSelectionMode ? -100 : 0, + 0, ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Кнопка 1', - Symbols.settings, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 12, + 20, + 4, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (_pullRatio < 0.8) + Opacity( + opacity: 1.0 - _pullRatio, + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only( + right: 8, + ), + child: Stack( + children: [ + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=dasha', + 0, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=mastika', + 1, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=stas', + 2, + ), + ], + ), + ), + ), + Text( + 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, + ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 16, + ), + ), + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Кнопка 1', + Symbols.settings, + ), + _buildPopupMenuItem( + 2, + 'Кнопка 2', + Symbols.notifications, + ), + _buildPopupMenuItem( + 3, + 'Кнопка 3', + Symbols.shield, + ), + _buildPopupMenuItem( + 4, + 'Кнопка 4', + Symbols.info, + ), + ], + ), + ], + ), ), - _buildPopupMenuItem( - 2, - 'Кнопка 2', - Symbols.notifications, + SizedBox( + height: 96 * _pullRatio, + child: Opacity( + opacity: _pullRatio, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + children: [ + _buildStoryItem( + 'Даша', + 'https://i.pravatar.cc/150?u=dasha', + true, + ), + _buildStoryItem( + 'Мастика', + 'https://i.pravatar.cc/150?u=mastika', + false, + ), + ], + ), + ), ), - _buildPopupMenuItem( - 3, - 'Кнопка 3', - Symbols.shield, - ), - _buildPopupMenuItem( - 4, - 'Кнопка 4', - Symbols.info, + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 4, + 20, + 12, + ), + child: Container( + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), ), ], - onSelected: (value) { - // Action handler - }, ), - ], - ), - ), - ), - SliverToBoxAdapter( - child: SizedBox( - height: 96, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 20), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, - ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], - ), - ), - ), - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - sliver: SliverToBoxAdapter( - child: Container( - height: 44, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, - ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, + ), + ), + SliverPadding( + padding: EdgeInsets.only( + top: _isSelectionMode ? 64 : 0, + ), + sliver: SliverToBoxAdapter( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + height: 48, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith( + dragDevices: { + ui.PointerDeviceKind.touch, + ui.PointerDeviceKind.mouse, + ui.PointerDeviceKind.trackpad, + }, ), + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, ), + physics: const BouncingScrollPhysics(), + children: [ + _buildFolderChip('Все чаты'), + const SizedBox(width: 8), + _buildFolderChip('Контакты'), + const SizedBox(width: 8), + _buildFolderChip('Пидоры'), + const SizedBox(width: 8), + _buildFolderChip('Каналы'), + const SizedBox(width: 8), + _buildFolderChip('Группы'), + const SizedBox(width: 8), + _buildFolderChip('Боты'), + const SizedBox(width: 8), + _buildFolderChip('Избранное'), + const SizedBox(width: 8), + _buildFolderChip('Архив'), + ], ), - ], - ), - ), - ), - ), - SliverToBoxAdapter( - child: SizedBox( - height: 48, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - ui.PointerDeviceKind.touch, - ui.PointerDeviceKind.mouse, - ui.PointerDeviceKind.trackpad, - }, - ), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, ), - physics: const BouncingScrollPhysics(), - children: [ - _buildFolderChip('Все чаты'), - const SizedBox(width: 8), - _buildFolderChip('Контакты'), - const SizedBox(width: 8), - _buildFolderChip('Пидоры'), - const SizedBox(width: 8), - _buildFolderChip('Каналы'), - const SizedBox(width: 8), - _buildFolderChip('Группы'), - const SizedBox(width: 8), - _buildFolderChip('Боты'), - const SizedBox(width: 8), - _buildFolderChip('Избранное'), - const SizedBox(width: 8), - _buildFolderChip('Архив'), - ], ), ), ), - ), - SliverList( - delegate: SliverChildListDelegate([ - _buildChatItem( - 'Станислав', - 'Хорошо', - '10:07', - 'https://i.pravatar.cc/150?u=stas', - isOnline: true, - isRead: true, - ), - _buildChatItem( - 'Илья', - 'печатает...', - '10:07', - 'https://i.pravatar.cc/150?u=ilya', - isOnline: true, - isTyping: true, - unreadCount: 1, - ), - _buildChatItem( - 'Вероника', - 'Спасибо', - '09:56', - 'https://i.pravatar.cc/150?u=veronika', - isRead: true, - ), - _buildChatItem( - 'Komet Client', - 'Кстати. Смотрите, какую шту...', - '09:56', - 'https://i.pravatar.cc/150?u=komet', - unreadCount: 5, - isMuted: true, - ), - _buildChatItem( - '4-й подъезд', - 'Людмила: Сколько?', - '09:34', - 'https://i.pravatar.cc/150?u=podezd', - unreadCount: 78, - isMuted: true, - ), - const SizedBox(height: 100), - ]), - ), - ], - ), + SliverList( + delegate: SliverChildListDelegate([ + _buildChatItem( + 'stas', + 'Станислав', + 'Хорошо', + '10:07', + 'https://i.pravatar.cc/150?u=stas', + isOnline: true, + isRead: true, + ), + _buildChatItem( + 'ilya', + 'Илья', + 'печатает...', + '10:07', + 'https://i.pravatar.cc/150?u=ilya', + isOnline: true, + isTyping: true, + unreadCount: 1, + ), + _buildChatItem( + 'veronika', + 'Вероника', + 'Спасибо', + '09:56', + 'https://i.pravatar.cc/150?u=veronika', + isRead: true, + ), + _buildChatItem( + 'komet', + 'Komet Client', + 'Кстати. Смотрите, какую шту...', + '09:56', + 'https://i.pravatar.cc/150?u=komet', + unreadCount: 5, + isMuted: true, + ), + _buildChatItem( + 'podezd', + '4-й подъезд', + 'Людмила: Сколько?', + '09:34', + 'https://i.pravatar.cc/150?u=podezd', + unreadCount: 78, + isMuted: true, + ), + ]), + ), + ], + ), // CustomScrollView + ), // Listener const CallsTab(), const ContactsTab(), const SettingsTab(), ], ), - Positioned( + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, left: 8, right: 8, - bottom: 24.0, + bottom: _isSelectionMode ? -100 : 24.0, child: RepaintBoundary( child: Container( height: 68, @@ -386,46 +532,99 @@ class _ChatListScreenState extends State ), ), ), - if (_fabController.value > 0) + if (!_isSelectionMode) ...[ + if (_fabController.value > 0) + Positioned( + right: 20, + bottom: 110 + 74, + child: RepaintBoundary( + child: Transform.scale( + scale: val, + alignment: Alignment.bottomRight, + child: Opacity( + opacity: val > 0.5 ? (val - 0.5) * 2 : 0, + child: _buildFabMenu(), + ), + ), + ), + ), Positioned( right: 20, - bottom: 110 + 74, - child: RepaintBoundary( - child: Transform.scale( - scale: val, - alignment: Alignment.bottomRight, - child: Opacity( - opacity: val > 0.5 ? (val - 0.5) * 2 : 0, - child: _buildFabMenu(), + bottom: 110, + child: FloatingActionButton( + onPressed: _toggleFab, + backgroundColor: cs.primaryContainer, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Transform.rotate( + angle: val * (pi / 4), + child: Icon( + Symbols.add, + color: cs.onPrimaryContainer, + size: 28, + weight: 400, ), ), ), ), - Positioned( - right: 20, - bottom: 110, - child: FloatingActionButton( - onPressed: _toggleFab, - backgroundColor: cs.primaryContainer, - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Transform.rotate( - angle: val * (pi / 4), - child: Icon( - Symbols.add, - color: cs.onPrimaryContainer, - size: 28, - weight: 400, - ), - ), - ), - ), + ], ], ); }, ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + top: _isSelectionMode ? 0 : -80, + left: 0, + right: 0, + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: cs.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _clearSelection, + ), + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.archive, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.volume_off, color: cs.onSurface), + onPressed: () {}, + ), + ], + ), + ), + ), ], ), ), @@ -489,6 +688,7 @@ class _ChatListScreenState extends State } Widget _buildChatItem( + String id, String name, String message, String time, @@ -500,87 +700,139 @@ class _ChatListScreenState extends State bool isMuted = false, }) { final cs = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2), - leading: Stack( - children: [ - CircleAvatar(radius: 24, backgroundImage: NetworkImage(imageUrl)), - if (isOnline) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), - ), - ), - ], - ), - title: Row( - children: [ - Expanded( - child: Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w600, - ), + final isSelected = _selectedChats.contains(id); + + return InkWell( + onTap: () { + if (_isSelectionMode) { + _toggleSelection(id); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen(name: name, imageUrl: imageUrl), ), + ); + } + }, + onLongPress: () => _toggleSelection(id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + color: isSelected ? cs.primary.withOpacity(0.08) : Colors.transparent, + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 2, ), - if (isMuted) - Icon( - Symbols.notifications_off, - color: cs.outlineVariant, - size: 14, - weight: 400, - ), - const SizedBox(width: 8), - Text(time, style: TextStyle(color: cs.outline, fontSize: 12)), - ], - ), - subtitle: Padding( - padding: const EdgeInsets.only(top: 4), - child: Row( - children: [ - Expanded( - child: Text( - message, - style: TextStyle( - color: isTyping ? cs.primary : cs.outline, - fontSize: 14, - fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (unreadCount > 0) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: isMuted - ? cs.surfaceContainerHighest - : cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), + leading: Stack( + children: [ + CircleAvatar(radius: 24, backgroundImage: NetworkImage(imageUrl)), + if (isSelected) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + child: Icon( + Symbols.check, + color: cs.onPrimary, + size: 14, + weight: 600, + ), + ), + ) + else if (isOnline) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + ), ), + ], + ), + title: Row( + children: [ + Expanded( child: Text( - unreadCount.toString(), + name, style: TextStyle( - color: isMuted ? cs.outline : cs.onSurface, - fontSize: 11, + color: cs.onSurface, + fontSize: 15, fontWeight: FontWeight.w600, ), ), - ) - else if (isRead) - Icon(Symbols.done_all, color: cs.primary, size: 16, weight: 400), - ], + ), + if (isMuted) + Icon( + Symbols.notifications_off, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + const SizedBox(width: 8), + Text(time, style: TextStyle(color: cs.outline, fontSize: 12)), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Expanded( + child: Text( + message, + style: TextStyle( + color: isTyping ? cs.primary : cs.outline, + fontSize: 14, + fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (unreadCount > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: isMuted + ? cs.surfaceContainerHighest + : cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + unreadCount.toString(), + style: TextStyle( + color: isMuted ? cs.outline : cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ) + else if (isRead) + Icon( + Symbols.done_all, + color: cs.primary, + size: 16, + weight: 400, + ), + ], + ), + ), ), ), ); @@ -714,4 +966,20 @@ class _ChatListScreenState extends State ), ); } + + Widget _buildFoldedStory(String imageUrl, int index) { + return Positioned( + left: index * 12.0, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.black, width: 2), + ), + child: CircleAvatar( + radius: 12, + backgroundImage: NetworkImage(imageUrl), + ), + ), + ); + } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e69de29..50f586d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'dart:ui'; + +class ChatScreen extends StatefulWidget { + final String name; + final String imageUrl; + + const ChatScreen({super.key, required this.name, required this.imageUrl}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final TextEditingController _messageController = TextEditingController(); + bool _hasText = false; + + @override + void initState() { + super.initState(); + _messageController.addListener(_onTextChanged); + } + + @override + void dispose() { + _messageController.removeListener(_onTextChanged); + _messageController.dispose(); + super.dispose(); + } + + void _onTextChanged() { + final bool newHasText = _messageController.text.trim().isNotEmpty; + if (newHasText != _hasText) { + setState(() { + _hasText = newHasText; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: const Color(0xFF1B1B1B).withOpacity(0.8), + elevation: 0, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon( + Symbols.arrow_back, + color: Colors.white, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + titleSpacing: 0, + title: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundImage: NetworkImage(widget.imageUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + widget.name, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + const Text( + 'Connecting...', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Symbols.call, color: Colors.white, weight: 400), + onPressed: () {}, + ), + IconButton( + icon: const Icon( + Symbols.more_vert, + color: Colors.white, + weight: 400, + ), + onPressed: () {}, + ), + ], + ), + body: Stack( + children: [ + Positioned.fill( + child: Image.network( + 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809', + fit: BoxFit.cover, + opacity: const AlwaysStoppedAnimation(0.4), + ), + ), + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], + ), + ), + ), + ), + Column(children: [const Spacer(), _buildInputArea(context)]), + ], + ), + ); + } + + Widget _buildInputArea(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + constraints: const BoxConstraints( + minHeight: 54, + maxHeight: 180, + ), + decoration: BoxDecoration( + color: const Color(0xFF1B1B1B).withOpacity(0.9), + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: Colors.white.withOpacity(0.1), + width: 0.5, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Symbols.face, + color: Colors.white.withOpacity(0.7), + size: 24, + weight: 400, + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _messageController, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + maxLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.center, + decoration: const InputDecoration( + hintText: 'Message', + hintStyle: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: _hasText ? 0 : 36, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _hasText ? 0 : 1, + child: _hasText + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(left: 12), + child: Icon( + Symbols.attachment, + color: Colors.white.withOpacity(0.7), + size: 24, + weight: 400, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Container( + width: 54, + height: 54, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: Color(0xFF2B2B2B), + shape: BoxShape.circle, + ), + child: Icon( + _hasText ? Symbols.send : Symbols.mic, + color: Colors.white, + size: 24, + weight: 400, + ), + ), + ], + ), + ), + ); + } +} From 7fcf36b71a3a30227c944e6c2d88a15a64559909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Thu, 26 Mar 2026 19:36:19 +0700 Subject: [PATCH 015/323] =?UTF-8?q?=D0=AD=D0=BA=D1=80=D0=B0=D0=BD=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/profile/settings_tab.dart | 217 +++++++++++++++++- 1 file changed, 209 insertions(+), 8 deletions(-) diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 2a60054..95e1f6a 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; class SettingsTab extends StatelessWidget { const SettingsTab({super.key}); @@ -7,15 +7,216 @@ class SettingsTab extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Center( - child: Text( - 'Настройки (Заглушка)', - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w500, + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildHeader(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildSection( + context, + cs, + items: const [ + _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), + _SettingsItem( + icon: Symbols.language, + label: 'Войти в сферум', + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSection( + context, + cs, + items: const [ + _SettingsItem( + icon: Symbols.notifications_active, + label: 'Уведомления и звук', + ), + _SettingsItem(icon: Symbols.lock, label: 'Безопасность'), + _SettingsItem(icon: Symbols.devices, label: 'Устройства'), + ], + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 120)), + ], ), ), ); } + + Widget _buildHeader(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 8, 20), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon( + Symbols.qr_code_2, + color: cs.onSurfaceVariant, + size: 26, + weight: 400, + ), + onPressed: () {}, + ), + IconButton( + icon: Icon( + Symbols.edit, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + onPressed: () {}, + ), + ], + ), + const SizedBox(height: 8), + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: ClipOval( + child: Image.network( + 'https://i.pravatar.cc/150?u=ilya', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => CircleAvatar( + backgroundColor: cs.primaryContainer, + child: Icon( + Symbols.person, + color: cs.onPrimaryContainer, + size: 40, + ), + ), + ), + ), + ), + const SizedBox(height: 14), + Text( + 'Илья Беларуских', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + const SizedBox(height: 3), + Text( + '@everrnyan', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ); + } + + Widget _buildSection( + BuildContext context, + ColorScheme cs, { + required List<_SettingsItem> items, + }) { + return ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: List.generate(items.length, (index) { + final item = items[index]; + final isLast = index == items.length - 1; + return _buildSettingsRow(context, cs, item, isLast: isLast); + }), + ), + ), + ); + } + + Widget _buildSettingsRow( + BuildContext context, + ColorScheme cs, + _SettingsItem item, { + bool isLast = false, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () {}, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + item.icon, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + item.label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } +} + +class _SettingsItem { + final IconData icon; + final String label; + + const _SettingsItem({required this.icon, required this.label}); } From ece0592889f4eda5ee7bea8386457bde35758a37 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 29 Mar 2026 10:39:02 +0300 Subject: [PATCH 016/323] =?UTF-8?q?=D1=81=D1=83=D0=BA=D0=B0=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B6=D0=B0=20=D0=BF=D0=B8=D0=B4=D0=BE=D1=80=D0=B0=D1=81=20?= =?UTF-8?q?=D0=B2=D0=BE=D1=82=20=D1=82=D0=B5=D0=B1=D0=B5=20=D0=BF=D0=B5?= =?UTF-8?q?=D0=B9=D0=BB=D0=BE=D0=B0=D0=B4=D1=8B=20=D1=81=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=BC=20=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 141 +++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index e69de29..1baf5e8 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -0,0 +1,141 @@ +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/utils/logger.dart'; + +enum AuthRequestType { + startAuth('START_AUTH'), + resend('RESEND'), + checkCode('CHECK_CODE'), + register('REGISTER'); + + const AuthRequestType(this.value); + final String value; +} + +class RequestCodeResult { + final String token; + + const RequestCodeResult({required this.token}); +} + +class VerifyCodeResult { + final Map payload; + + const VerifyCodeResult({required this.payload}); + + String? get loginToken => _nestedToken('LOGIN'); + + String? get registerToken => _nestedToken('REGISTER'); + + bool get requiresPassword => payload['passwordChallenge'] != null; + + Map? get passwordChallenge { + final c = payload['passwordChallenge']; + return c is Map ? c.cast() : null; + } + + String? _nestedToken(String key) { + final attrs = payload['tokenAttrs']; + if (attrs is! Map) return null; + final entry = attrs[key]; + if (entry is! Map) return null; + return entry['token'] as String?; + } +} + +/// Поток авторизации по номеру телефона: +/// 1. [requestCode] → сервер шлёт SMS, возвращает временный токен. +/// 2. [verifyCode] → клиент отправляет код + токен, получает токен сессии. +/// +/// При необходимости повторной отправки SMS используйте [resendCode]. +class AccountModule { + final Api _api; + + AccountModule(this._api); + + Future requestCode( + String phone, { + String language = 'ru', + }) => + _requestCodeInternal(phone, AuthRequestType.startAuth, language); + + Future resendCode( + String phone, { + String language = 'ru', + }) => + _requestCodeInternal(phone, AuthRequestType.resend, language); + + Future verifyCode(String code, String token) async { + _ensureOnline(); + + final payload = { + 'token': token, + 'verifyCode': code, + 'authTokenType': AuthRequestType.checkCode.value, + }; + + logger.i('Отправка OTP-кода (opcode=${Opcode.auth})'); + + final packet = await _api.sendRequest(Opcode.auth, payload); + + _checkPacketError(packet, 'verifyCode'); + + final data = packet.payload; + if (data is! Map) { + throw Exception('verifyCode: неожиданный тип payload: ${data.runtimeType}'); + } + + return VerifyCodeResult(payload: data.cast()); + } + + Future _requestCodeInternal( + String phone, + AuthRequestType type, + String language, + ) async { + _ensureOnline(); + + final payload = { + 'phone': phone, + 'type': type.value, + 'language': language, + }; + + logger.i('Запрос OTP-кода: phone=$phone type=${type.value}'); + + final packet = await _api.sendRequest(Opcode.authRequest, payload); + + _checkPacketError(packet, 'requestCode'); + + final data = packet.payload; + if (data is! Map) { + throw Exception('requestCode: неожиданный тип payload: ${data.runtimeType}'); + } + + final token = data['token']; + if (token is! String || token.isEmpty) { + throw Exception('requestCode: отсутствует token в ответе сервера'); + } + + logger.i('OTP-код запрошен, получен временный токен'); + return RequestCodeResult(token: token); + } + + void _ensureOnline() { + if (_api.state != SessionState.online) { + throw StateError( + 'AccountModule: сессия не онлайн (текущее состояние: ${_api.state.name})', + ); + } + } + + void _checkPacketError(Packet packet, String method) { + if (packet.isError) { + final errMsg = packet.payload is Map + ? (packet.payload as Map)['message'] ?? packet.payload.toString() + : packet.payload?.toString() ?? 'unknown error'; + throw Exception('$method: ошибка от сервера — $errMsg'); + } + } +} From 73eaff142b8086d33f2675de5f7172ab513f6216 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 29 Mar 2026 10:48:52 +0300 Subject: [PATCH 017/323] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B5?= =?UTF-8?q?=20=D0=9E=D0=A1=20=D0=BD=D0=B0=20Android=20=D1=8D=D1=82=D0=BE?= =?UTF-8?q?=20=D0=B2=20EncryptedSharedPreferences=20=D0=B2=20=D1=8F=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D1=87=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=8D=D1=82=D0=BE=20Keychain=20=D1=81=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=BD=D0=B4=D0=BE=D0=B9=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=B5=D1=82=20=D0=BD=D0=BE=20=D1=8F=20=D1=85=D0=B7?= =?UTF-8?q?=20=D1=87=D0=B5=20=D1=82=D0=B0=D0=BC=20=D0=B7=D0=B0=20=D1=85?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B5=20=D1=81=20?= =?UTF-8?q?=D0=BB=D0=B8=D0=BD=D1=83=D1=85=D0=BE=D0=BC=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=B5=D1=82,=20=D0=BD=D0=BE=20=D0=BD=D1=83?= =?UTF-8?q?=D0=B6=D0=BD=D0=BE=20=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B4=D0=BE=D0=BF.=20=D0=BF=D0=B0=D0=BA=D0=B5?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Debian / Ubuntu sudo apt install libsecret-1-dev # Fedora / RHEL sudo dnf install libsecret-devel # Arch sudo pacman -S libsecret --- lib/backend/modules/account.dart | 11 +- lib/core/storage/token_storage.dart | 16 +++ linux/CMakeLists.txt | 1 + pubspec.lock | 186 +++++++++++++++++++++++++++- pubspec.yaml | 1 + 5 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 lib/core/storage/token_storage.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 1baf5e8..cac66ce 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,6 +1,7 @@ import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; +import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; enum AuthRequestType { @@ -86,7 +87,15 @@ class AccountModule { throw Exception('verifyCode: неожиданный тип payload: ${data.runtimeType}'); } - return VerifyCodeResult(payload: data.cast()); + final result = VerifyCodeResult(payload: data.cast()); + + final sessionToken = result.loginToken ?? result.registerToken; + if (sessionToken != null) { + await TokenStorage.save(sessionToken); + logger.i('Токен сохранён в хранилище'); + } + + return result; } Future _requestCodeInternal( diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..a33c3ee --- /dev/null +++ b/lib/core/storage/token_storage.dart @@ -0,0 +1,16 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class TokenStorage { + static const _key = 'auth_token'; + + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + + static Future save(String token) => + _storage.write(key: _key, value: token); + + static Future read() => _storage.read(key: _key); + + static Future delete() => _storage.delete(key: _key); +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 74a50c9..ed7975d 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -53,6 +53,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR}) # System-level dependencies. find_package(PkgConfig REQUIRED) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(SECRET REQUIRED IMPORTED_TARGET libsecret-1) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") diff --git a/pubspec.lock b/pubspec.lock index 6bb2187..e7a8ffd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -41,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -118,6 +134,54 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -136,6 +200,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" http: dependency: transitive description: @@ -192,6 +272,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -224,6 +312,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" path: dependency: transitive description: @@ -232,6 +336,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + url: "https://pub.dev" + source: hosted + version: "2.2.23" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -240,6 +400,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" sky_engine: dependency: transitive description: flutter @@ -349,6 +517,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.10.4 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 252d74e..5ae7d5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: device_info_plus: ^12.3.0 flutter_timezone: ^5.0.1 timezone: ^0.11.0 + flutter_secure_storage: ^10.0.0 dev_dependencies: flutter_test: From c3e825ec186bf651bb125265d33e065ca06b1b2d Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 29 Mar 2026 11:33:13 +0300 Subject: [PATCH 018/323] feat: add phone auth with 2FA, multi-account storage --- lib/backend/modules/account.dart | 299 +++++++++++++++++++++++++++- lib/core/storage/app_database.dart | 261 ++++++++++++++++++++++++ lib/core/storage/token_storage.dart | 36 +++- lib/main.dart | 2 + pubspec.lock | 66 +++++- pubspec.yaml | 3 + 6 files changed, 653 insertions(+), 14 deletions(-) create mode 100644 lib/core/storage/app_database.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index cac66ce..d73ec1f 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,6 +1,7 @@ import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; @@ -36,6 +37,20 @@ class VerifyCodeResult { return c is Map ? c.cast() : null; } + /// trackId из passwordChallenge — передаётся в [AccountModule.checkPassword]. + String? get challengeTrackId => passwordChallenge?['trackId'] as String?; + + /// Подсказка к паролю из passwordChallenge. + String? get challengeHint => passwordChallenge?['hint'] as String?; + + int? get accountId { + final profileData = payload['profile']; + if (profileData is! Map) return null; + final contact = profileData['contact']; + if (contact is! Map) return null; + return contact['id'] as int?; + } + String? _nestedToken(String key) { final attrs = payload['tokenAttrs']; if (attrs is! Map) return null; @@ -45,11 +60,68 @@ class VerifyCodeResult { } } -/// Поток авторизации по номеру телефона: -/// 1. [requestCode] → сервер шлёт SMS, возвращает временный токен. -/// 2. [verifyCode] → клиент отправляет код + токен, получает токен сессии. -/// -/// При необходимости повторной отправки SMS используйте [resendCode]. +class TwoFactorResult { + final String loginToken; + + const TwoFactorResult({required this.loginToken}); +} + +/// При отсутствии [LoginSyncParams] в [AccountModule.login] сервер вернёт +/// полный снимок данных (cold start), иначе только дельту (warm start). +class LoginSyncParams { + final int chatsSync; + final int contactsSync; + final int callsSync; + final int draftsSync; + final int bannersSync; + final int presenceSync; + final int lastLogin; + final String? configHash; + final String? chatCacheFingerprint; + + const LoginSyncParams({ + required this.chatsSync, + required this.contactsSync, + required this.callsSync, + required this.draftsSync, + required this.bannersSync, + required this.presenceSync, + required this.lastLogin, + this.configHash, + this.chatCacheFingerprint, + }); + + static Future fromDatabase(int accountId) async { + final values = await AppDatabase.getAllSyncValues(accountId); + final lastLogin = values[SyncKey.lastLogin]; + if (lastLogin == null) return null; + + return LoginSyncParams( + chatsSync: int.tryParse(values[SyncKey.chatsSync] ?? '') ?? 0, + contactsSync: int.tryParse(values[SyncKey.contactsSync] ?? '') ?? 0, + callsSync: int.tryParse(values[SyncKey.callsSync] ?? '') ?? 0, + draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0, + bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0, + presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1, + lastLogin: int.parse(lastLogin), + configHash: values[SyncKey.configHash], + chatCacheFingerprint: values[SyncKey.chatCacheFingerprint], + ); + } +} + +class LoginResult { + final ProfileData profile; + final String? updatedToken; + final int serverTime; + + const LoginResult({ + required this.profile, + required this.updatedToken, + required this.serverTime, + }); +} + class AccountModule { final Api _api; @@ -90,14 +162,225 @@ class AccountModule { final result = VerifyCodeResult(payload: data.cast()); final sessionToken = result.loginToken ?? result.registerToken; - if (sessionToken != null) { - await TokenStorage.save(sessionToken); - logger.i('Токен сохранён в хранилище'); + final accountId = result.accountId; + + if (sessionToken != null && accountId != null) { + await TokenStorage.saveToken(sessionToken, accountId); + await TokenStorage.setActiveAccount(accountId); + logger.i('Токен аккаунта $accountId сохранён, установлен активным'); } return result; } + Future login({ + int? accountId, + String? token, + LoginSyncParams? syncParams, + }) async { + _ensureOnline(); + + final resolvedAccountId = accountId ?? await TokenStorage.getActiveAccountId(); + if (resolvedAccountId == null) { + throw StateError('login: нет активного аккаунта'); + } + + final authToken = token ?? await TokenStorage.readToken(resolvedAccountId); + if (authToken == null) { + throw StateError('login: нет токена для аккаунта $resolvedAccountId'); + } + + final resolvedSyncParams = + syncParams ?? await LoginSyncParams.fromDatabase(resolvedAccountId); + + final requestPayload = _buildLoginPayload(authToken, resolvedSyncParams); + + logger.i('LOGIN opcode=${Opcode.login} ' + 'account=$resolvedAccountId warm=${resolvedSyncParams != null}'); + + final packet = await _api.sendRequest(Opcode.login, requestPayload); + + _checkPacketError(packet, 'login'); + + final data = packet.payload; + if (data is! Map) { + throw Exception('login: неожиданный тип payload: ${data.runtimeType}'); + } + + return _processLoginResponse( + data.cast(), + resolvedAccountId, + ); + } + + Future switchAccount(int accountId) async { + final profile = await AppDatabase.loadProfile(accountId); + if (profile == null) { + throw StateError('switchAccount: аккаунт $accountId не найден в базе'); + } + + await AppDatabase.setActiveAccount(accountId); + await TokenStorage.setActiveAccount(accountId); + + logger.i('Активный аккаунт переключён на $accountId'); + return profile; + } + + Future removeAccount(int accountId) async { + await AppDatabase.deleteAccount(accountId); + await TokenStorage.deleteAccount(accountId); + logger.i('Аккаунт $accountId удалён локально'); + } + + /// Проверяет 2FA-пароль (opcode 115). + /// + /// [trackId] — из [VerifyCodeResult.challengeTrackId]. + /// [accountId] — из [VerifyCodeResult.accountId]. + /// + /// При неверном пароле бросает [Exception]. + /// При успехе сохраняет токен и устанавливает аккаунт активным. + Future checkPassword({ + required String password, + required String trackId, + required int accountId, + }) async { + _ensureOnline(); + + final payload = { + 'trackId': trackId, + 'password': password, + }; + + logger.i('Проверка 2FA-пароля для аккаунта $accountId'); + + final packet = await _api.sendRequest( + Opcode.authLoginCheckPassword, + payload, + ); + + _checkPacketError(packet, 'checkPassword'); + + final data = packet.payload; + if (data is! Map) { + throw Exception('checkPassword: неожиданный тип payload: ${data.runtimeType}'); + } + + if (data['error'] != null) { + throw Exception('checkPassword: неверный пароль'); + } + + final tokenAttrs = data['tokenAttrs']; + if (tokenAttrs is! Map) { + throw Exception('checkPassword: отсутствует tokenAttrs в ответе'); + } + + final loginEntry = tokenAttrs['LOGIN']; + if (loginEntry is! Map) { + throw Exception('checkPassword: отсутствует tokenAttrs.LOGIN в ответе'); + } + + final loginToken = loginEntry['token'] as String?; + if (loginToken == null || loginToken.isEmpty) { + throw Exception('checkPassword: отсутствует токен в ответе'); + } + + await TokenStorage.saveToken(loginToken, accountId); + await TokenStorage.setActiveAccount(accountId); + logger.i('2FA пройдена, токен аккаунта $accountId сохранён'); + + return TwoFactorResult(loginToken: loginToken); + } + + Map _buildLoginPayload( + String token, + LoginSyncParams? sync, + ) { + final payload = { + 'token': token, + 'interactive': true, + 'exp': {'chatsCountGroups': '0b32'}, + }; + + if (sync != null) { + payload['presenceSync'] = sync.presenceSync; + payload['chatsSync'] = sync.chatsSync; + payload['contactsSync'] = sync.contactsSync; + payload['callsSync'] = sync.callsSync; + payload['draftsSync'] = sync.draftsSync; + payload['bannersSync'] = sync.bannersSync; + payload['lastLogin'] = sync.lastLogin; + if (sync.configHash != null) payload['configHash'] = sync.configHash; + if (sync.chatCacheFingerprint != null) { + payload['chatCacheFingerprint'] = sync.chatCacheFingerprint; + } + } else { + payload['presenceSync'] = 0; + } + + return payload; + } + + Future _processLoginResponse( + Map data, + int accountId, + ) async { + final serverTime = + (data['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch; + + final updatedToken = data['token'] as String?; + if (updatedToken != null) { + await TokenStorage.saveToken(updatedToken, accountId); + logger.i('Обновлённый токен аккаунта $accountId сохранён'); + } + + final profileMap = data['profile']; + if (profileMap is! Map) { + throw Exception('login: отсутствует profile в ответе'); + } + final contact = profileMap['contact']; + if (contact is! Map) { + throw Exception('login: отсутствует profile.contact в ответе'); + } + final profile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(profile); + await AppDatabase.setActiveAccount(profile.id); + logger.i('Профиль сохранён: id=${profile.id}, name=${profile.firstName}'); + + await _saveSyncState(data, serverTime, profile.id); + + return LoginResult( + profile: profile, + updatedToken: updatedToken, + serverTime: serverTime, + ); + } + + Future _saveSyncState( + Map data, + int serverTime, + int accountId, + ) async { + final ts = serverTime.toString(); + + Future set(String key, String value) => + AppDatabase.setSyncValue(accountId, key, value); + + await set(SyncKey.serverTime, ts); + await set(SyncKey.lastLogin, ts); + await set(SyncKey.chatsSync, ts); + await set(SyncKey.contactsSync, ts); + await set(SyncKey.callsSync, ts); + await set(SyncKey.draftsSync, ts); + await set(SyncKey.bannersSync, ts); + await set(SyncKey.presenceSync, '-1'); + + final config = data['config']; + if (config is Map) { + final hash = config['hash'] as String?; + if (hash != null) await set(SyncKey.configHash, hash); + } + } + Future _requestCodeInternal( String phone, AuthRequestType type, diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart new file mode 100644 index 0000000..8542c77 --- /dev/null +++ b/lib/core/storage/app_database.dart @@ -0,0 +1,261 @@ +import 'dart:io'; + +import 'package:path/path.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class ProfileData { + final int id; + final String firstName; + final String? lastName; + final int phone; + final int? photoId; + final String? baseUrl; + final String? baseRawUrl; + final String country; + final int accountStatus; + final int updateTime; + + ProfileData({ + required this.id, + required this.firstName, + this.lastName, + required this.phone, + this.photoId, + this.baseUrl, + this.baseRawUrl, + required this.country, + required this.accountStatus, + required this.updateTime, + }); + + factory ProfileData.fromServerMap(Map contact) { + final names = contact['names']; + String firstName = ''; + String? lastName; + + if (names is List && names.isNotEmpty) { + final name = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) as Map; + firstName = (name['firstName'] as String?) ?? ''; + lastName = name['lastName'] as String?; + } + + return ProfileData( + id: contact['id'] as int, + firstName: firstName, + lastName: lastName, + phone: contact['phone'] as int, + photoId: contact['photoId'] as int?, + baseUrl: contact['baseUrl'] as String?, + baseRawUrl: contact['baseRawUrl'] as String?, + country: (contact['country'] as String?) ?? '', + accountStatus: (contact['accountStatus'] as int?) ?? 0, + updateTime: (contact['updateTime'] as int?) ?? 0, + ); + } + + factory ProfileData.fromDbRow(Map row) { + return ProfileData( + id: row['id'] as int, + firstName: row['first_name'] as String, + lastName: row['last_name'] as String?, + phone: row['phone'] as int, + photoId: row['photo_id'] as int?, + baseUrl: row['base_url'] as String?, + baseRawUrl: row['base_raw_url'] as String?, + country: row['country'] as String, + accountStatus: row['account_status'] as int, + updateTime: row['update_time'] as int, + ); + } + + Map toDbRow() => { + 'id': id, + 'first_name': firstName, + 'last_name': lastName, + 'phone': phone, + 'photo_id': photoId, + 'base_url': baseUrl, + 'base_raw_url': baseRawUrl, + 'country': country, + 'account_status': accountStatus, + 'update_time': updateTime, + }; +} + +abstract class SyncKey { + static const chatsSync = 'chats_sync'; + static const contactsSync = 'contacts_sync'; + static const callsSync = 'calls_sync'; + static const draftsSync = 'drafts_sync'; + static const bannersSync = 'banners_sync'; + static const presenceSync = 'presence_sync'; + static const lastLogin = 'last_login'; + static const configHash = 'config_hash'; + static const chatCacheFingerprint = 'chat_cache_fingerprint'; + static const serverTime = 'server_time'; +} + +class AppDatabase { + static Database? _db; + + static Future init() async { + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + } + } + + static Future get _instance async { + _db ??= await _open(); + return _db!; + } + + static Future _open() async { + final dbPath = await getDatabasesPath(); + return openDatabase( + join(dbPath, 'komet.db'), + version: 2, + onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), + onCreate: (db, _) => _createTables(db), + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + await db.execute( + 'ALTER TABLE profile ADD COLUMN is_active INTEGER NOT NULL DEFAULT 0', + ); + await db.execute('DROP TABLE IF EXISTS sync_state'); + await db.execute(_syncStateSchema); + } + }, + ); + } + + static Future _createTables(Database db) async { + await db.execute(''' + CREATE TABLE profile ( + id INTEGER PRIMARY KEY, + first_name TEXT NOT NULL, + last_name TEXT, + phone INTEGER NOT NULL, + photo_id INTEGER, + base_url TEXT, + base_raw_url TEXT, + country TEXT NOT NULL DEFAULT '', + account_status INTEGER NOT NULL DEFAULT 0, + update_time INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 0 + ) + '''); + await db.execute(_syncStateSchema); + } + + static const _syncStateSchema = ''' + CREATE TABLE sync_state ( + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (account_id, key) + ) + '''; + + static Future saveProfile(ProfileData profile) async { + final db = await _instance; + await db.insert( + 'profile', + profile.toDbRow(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + static Future loadProfile(int accountId) async { + final db = await _instance; + final rows = await db.query( + 'profile', + where: 'id = ?', + whereArgs: [accountId], + limit: 1, + ); + if (rows.isEmpty) return null; + return ProfileData.fromDbRow(rows.first); + } + + static Future> loadAllProfiles() async { + final db = await _instance; + final rows = await db.query('profile', orderBy: 'is_active DESC, id ASC'); + return rows.map(ProfileData.fromDbRow).toList(); + } + + static Future loadActiveProfile() async { + final db = await _instance; + final rows = await db.query( + 'profile', + where: 'is_active = 1', + limit: 1, + ); + if (rows.isEmpty) return null; + return ProfileData.fromDbRow(rows.first); + } + + static Future setActiveAccount(int accountId) async { + final db = await _instance; + await db.transaction((txn) async { + await txn.update('profile', {'is_active': 0}); + await txn.update( + 'profile', + {'is_active': 1}, + where: 'id = ?', + whereArgs: [accountId], + ); + }); + } + + static Future deleteAccount(int accountId) async { + final db = await _instance; + await db.delete('profile', where: 'id = ?', whereArgs: [accountId]); + } + + static Future setSyncValue( + int accountId, + String key, + String value, + ) async { + final db = await _instance; + await db.insert( + 'sync_state', + {'account_id': accountId, 'key': key, 'value': value}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + static Future getSyncValue(int accountId, String key) async { + final db = await _instance; + final rows = await db.query( + 'sync_state', + where: 'account_id = ? AND key = ?', + whereArgs: [accountId, key], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String; + } + + static Future> getAllSyncValues(int accountId) async { + final db = await _instance; + final rows = await db.query( + 'sync_state', + where: 'account_id = ?', + whereArgs: [accountId], + ); + return { + for (final row in rows) row['key'] as String: row['value'] as String, + }; + } + + static Future close() async { + await _db?.close(); + _db = null; + } +} diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index a33c3ee..657ae14 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -1,16 +1,42 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class TokenStorage { - static const _key = 'auth_token'; + static const _tokenPrefix = 'auth_token_'; + static const _activeAccountKey = 'active_account_id'; static const _storage = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), ); - static Future save(String token) => - _storage.write(key: _key, value: token); + static Future saveToken(String token, int accountId) => + _storage.write(key: '$_tokenPrefix$accountId', value: token); - static Future read() => _storage.read(key: _key); + static Future readToken(int accountId) => + _storage.read(key: '$_tokenPrefix$accountId'); - static Future delete() => _storage.delete(key: _key); + static Future deleteToken(int accountId) => + _storage.delete(key: '$_tokenPrefix$accountId'); + + static Future setActiveAccount(int accountId) => + _storage.write(key: _activeAccountKey, value: accountId.toString()); + + static Future getActiveAccountId() async { + final val = await _storage.read(key: _activeAccountKey); + return val != null ? int.tryParse(val) : null; + } + + static Future readActiveToken() async { + final id = await getActiveAccountId(); + if (id == null) return null; + return readToken(id); + } + + /// Удаляет токен аккаунта и, если он был активным, сбрасывает активный аккаунт. + static Future deleteAccount(int accountId) async { + await deleteToken(accountId); + final activeId = await getActiveAccountId(); + if (activeId == accountId) { + await _storage.delete(key: _activeAccountKey); + } + } } diff --git a/lib/main.dart b/lib/main.dart index 429dfbf..edd81ce 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'backend/api.dart'; +import 'core/storage/app_database.dart'; final api = Api(); void main() async { WidgetsFlutterBinding.ensureInitialized(); + await AppDatabase.init(); await api.connect(); runApp(const MyApp()); } diff --git a/pubspec.lock b/pubspec.lock index e7a8ffd..b8e8b82 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -329,7 +329,7 @@ packages: source: hosted version: "9.3.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -421,6 +421,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff + url: "https://pub.dev" + source: hosted + version: "2.4.0+2" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: caa693ad15a587a2b4fde093b728131a1827903872171089dedb16f7665d3a91 + url: "https://pub.dev" + source: hosted + version: "3.2.0" stack_trace: dependency: transitive description: @@ -445,6 +501,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5ae7d5e..3159405 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,9 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 flutter_secure_storage: ^10.0.0 + sqflite: ^2.4.2 + sqflite_common_ffi: ^2.4.0+2 + path: ^1.9.1 dev_dependencies: flutter_test: From 610527b0932f7e09688ef784bfbd964035f361bd Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 29 Mar 2026 11:53:23 +0300 Subject: [PATCH 019/323] feat: implement chat caching and synchronization from login payload --- lib/backend/modules/account.dart | 2 + lib/backend/modules/chats.dart | 204 +++++++++++++++++++++++++++++ lib/core/storage/app_database.dart | 50 ++++++- 3 files changed, 255 insertions(+), 1 deletion(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index d73ec1f..a2ce112 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -4,6 +4,7 @@ import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; +import 'chats.dart'; enum AuthRequestType { startAuth('START_AUTH'), @@ -347,6 +348,7 @@ class AccountModule { logger.i('Профиль сохранён: id=${profile.id}, name=${profile.firstName}'); await _saveSyncState(data, serverTime, profile.id); + await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id); return LoginResult( profile: profile, diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index e69de29..d9c7d91 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -0,0 +1,204 @@ +import '../../core/storage/app_database.dart'; + +class CachedChat { + final int id; + final int accountId; + final String type; + final String? title; + final String? iconUrl; + final int? lastMsgId; + final int? lastMsgTime; + final String? lastMsgText; + final int? lastMsgSenderId; + final int unreadCount; + final int lastEventTime; + final int cachedAt; + + const CachedChat({ + required this.id, + required this.accountId, + required this.type, + this.title, + this.iconUrl, + this.lastMsgId, + this.lastMsgTime, + this.lastMsgText, + this.lastMsgSenderId, + required this.unreadCount, + required this.lastEventTime, + required this.cachedAt, + }); + + factory CachedChat.fromDbRow(Map row) => CachedChat( + id: row['id'] as int, + accountId: row['account_id'] as int, + type: row['type'] as String, + title: row['title'] as String?, + iconUrl: row['icon_url'] as String?, + lastMsgId: row['last_msg_id'] as int?, + lastMsgTime: row['last_msg_time'] as int?, + lastMsgText: row['last_msg_text'] as String?, + lastMsgSenderId: row['last_msg_sender'] as int?, + unreadCount: row['unread_count'] as int, + lastEventTime: row['last_event_time'] as int, + cachedAt: row['cached_at'] as int, + ); + + Map toDbRow() => { + 'id': id, + 'account_id': accountId, + 'type': type, + 'title': title, + 'icon_url': iconUrl, + 'last_msg_id': lastMsgId, + 'last_msg_time': lastMsgTime, + 'last_msg_text': lastMsgText, + 'last_msg_sender': lastMsgSenderId, + 'unread_count': unreadCount, + 'last_event_time': lastEventTime, + 'cached_at': cachedAt, + }; +} + +class ChatsModule { + /// Парсит и кэширует чаты из payload opcode 19. + /// + /// Для диалогов разрезолвит имя и аватар из списка [contacts] того же + /// ответа. На warm start контакты не приходят — используется существующий + /// кэш. + static Future syncFromLoginPayload( + Map data, + int accountId, + int currentUserId, + ) async { + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return; + + final contactsMap = _buildContactsMap(data['contacts']); + final cachedAt = DateTime.now().millisecondsSinceEpoch; + + final existingRows = await AppDatabase.loadChats(accountId); + final existing = { + for (final row in existingRows) row['id'] as int: CachedChat.fromDbRow(row), + }; + + final rows = chats + .whereType() + .map((c) => _parseChat( + c.cast(), + accountId, + currentUserId, + contactsMap, + existing, + cachedAt, + )) + .whereType() + .map((c) => c.toDbRow()) + .toList(); + + if (rows.isNotEmpty) { + await AppDatabase.saveChats(rows); + } + } + + static Future> getChats(int accountId) async { + final rows = await AppDatabase.loadChats(accountId); + return rows.map(CachedChat.fromDbRow).toList(); + } + + static Future clearCache(int accountId) => + AppDatabase.clearChatsCache(accountId); + + // internal + + static Map> _buildContactsMap(dynamic contacts) { + if (contacts is! List) return {}; + final result = >{}; + for (final c in contacts.whereType()) { + final id = c['id']; + if (id is int) result[id] = c.cast(); + } + return result; + } + + static CachedChat? _parseChat( + Map chat, + int accountId, + int currentUserId, + Map> contactsMap, + Map existing, + int cachedAt, + ) { + final id = chat['id']; + if (id is! int) return null; + + final type = (chat['type'] as String?) ?? 'DIALOG'; + + String? title; + String? iconUrl; + + if (type == 'DIALOG') { + final otherId = _otherParticipantId(chat['participants'], currentUserId); + final contact = otherId != null ? contactsMap[otherId] : null; + + if (contact != null) { + title = _nameFromContact(contact); + iconUrl = contact['baseUrl'] as String?; + } else { + // Warm start: контакты не пришли — берём из кэша + title = existing[id]?.title; + iconUrl = existing[id]?.iconUrl; + } + } else { + title = chat['title'] as String?; + iconUrl = chat['baseIconUrl'] as String?; + } + + final lastMsg = chat['lastMessage']; + int? lastMsgId; + int? lastMsgTime; + String? lastMsgText; + int? lastMsgSenderId; + + if (lastMsg is Map) { + lastMsgId = lastMsg['id'] as int?; + lastMsgTime = lastMsg['time'] as int?; + lastMsgText = lastMsg['text'] as String?; + lastMsgSenderId = lastMsg['sender'] as int?; + } + + return CachedChat( + id: id, + accountId: accountId, + type: type, + title: title, + iconUrl: iconUrl, + lastMsgId: lastMsgId, + lastMsgTime: lastMsgTime, + lastMsgText: lastMsgText, + lastMsgSenderId: lastMsgSenderId, + unreadCount: (chat['newMessages'] as int?) ?? 0, + lastEventTime: (chat['lastEventTime'] as int?) ?? 0, + cachedAt: cachedAt, + ); + } + + static int? _otherParticipantId(dynamic participants, int currentUserId) { + if (participants is! Map) return null; + for (final key in participants.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null && id != currentUserId) return id; + } + return null; + } + + static String? _nameFromContact(Map contact) { + final names = contact['names']; + if (names is! List || names.isEmpty) return null; + final name = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) as Map; + return name['name'] as String?; + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 8542c77..3c0409a 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -118,7 +118,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 2, + version: 3, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -129,6 +129,9 @@ class AppDatabase { await db.execute('DROP TABLE IF EXISTS sync_state'); await db.execute(_syncStateSchema); } + if (oldVersion < 3) { + await db.execute(_chatsCacheSchema); + } }, ); } @@ -150,6 +153,7 @@ class AppDatabase { ) '''); await db.execute(_syncStateSchema); + await db.execute(_chatsCacheSchema); } static const _syncStateSchema = ''' @@ -161,6 +165,24 @@ class AppDatabase { ) '''; + static const _chatsCacheSchema = ''' + CREATE TABLE chats_cache ( + id INTEGER NOT NULL, + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + type TEXT NOT NULL, + title TEXT, + icon_url TEXT, + last_msg_id INTEGER, + last_msg_time INTEGER, + last_msg_text TEXT, + last_msg_sender INTEGER, + unread_count INTEGER NOT NULL DEFAULT 0, + last_event_time INTEGER NOT NULL DEFAULT 0, + cached_at INTEGER NOT NULL, + PRIMARY KEY (id, account_id) + ) + '''; + static Future saveProfile(ProfileData profile) async { final db = await _instance; await db.insert( @@ -258,4 +280,30 @@ class AppDatabase { await _db?.close(); _db = null; } + + // Chats cache + + static Future saveChats(List> rows) async { + final db = await _instance; + final batch = db.batch(); + for (final row in rows) { + batch.insert('chats_cache', row, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + static Future>> loadChats(int accountId) async { + final db = await _instance; + return db.query( + 'chats_cache', + where: 'account_id = ?', + whereArgs: [accountId], + orderBy: 'last_event_time DESC', + ); + } + + static Future clearChatsCache(int accountId) async { + final db = await _instance; + await db.delete('chats_cache', where: 'account_id = ?', whereArgs: [accountId]); + } } From fc52f91912b89f81c841eb96de2215172096e50e Mon Sep 17 00:00:00 2001 From: noxzion Date: Wed, 1 Apr 2026 22:50:00 +0500 Subject: [PATCH 020/323] =?UTF-8?q?=D0=9B=D0=BE=D0=B3=D0=B8=D0=BD=20flow,?= =?UTF-8?q?=20=D0=BD=D0=B5=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81i=D0=BAi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 25 +- lib/core/config/countries.dart | 768 +++++++++--------- lib/core/protocol/packet.dart | 10 +- lib/core/utils/logger.dart | 7 +- .../auth/code_confirmation_screen.dart | 83 +- lib/frontend/screens/auth/login_screen.dart | 34 +- .../screens/auth/password_2fa_screen.dart | 184 +++++ lib/main.dart | 4 + pubspec.lock | 131 ++- 9 files changed, 782 insertions(+), 464 deletions(-) create mode 100644 lib/frontend/screens/auth/password_2fa_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index a2ce112..57ce448 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -243,7 +243,6 @@ class AccountModule { Future checkPassword({ required String password, required String trackId, - required int accountId, }) async { _ensureOnline(); @@ -252,7 +251,7 @@ class AccountModule { 'password': password, }; - logger.i('Проверка 2FA-пароля для аккаунта $accountId'); + logger.i('Проверка 2FA-пароля'); final packet = await _api.sendRequest( Opcode.authLoginCheckPassword, @@ -285,9 +284,22 @@ class AccountModule { throw Exception('checkPassword: отсутствует токен в ответе'); } - await TokenStorage.saveToken(loginToken, accountId); - await TokenStorage.setActiveAccount(accountId); - logger.i('2FA пройдена, токен аккаунта $accountId сохранён'); + final profileData = data['profile']; + int? accountId; + if (profileData is Map) { + final contact = profileData['contact']; + if (contact is Map) { + accountId = contact['id'] as int?; + } + } + + if (accountId != null) { + await TokenStorage.saveToken(loginToken, accountId); + await TokenStorage.setActiveAccount(accountId); + logger.i('2FA пройдена, токен аккаунта $accountId сохранён'); + } else { + logger.w('2FA пройдена, но accountId не получен из ответа'); + } return TwoFactorResult(loginToken: loginToken); } @@ -298,8 +310,7 @@ class AccountModule { ) { final payload = { 'token': token, - 'interactive': true, - 'exp': {'chatsCountGroups': '0b32'}, + 'interactive': true }; if (sync != null) { diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart index 1c65b81..d19d055 100644 --- a/lib/core/config/countries.dart +++ b/lib/core/config/countries.dart @@ -80,393 +80,393 @@ List _buildCountries() { } const String _countriesEnJson = '''[ -{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Afghanistan", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Albania", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Algeria", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Andorra", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]} -{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Angola", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Antigua and Barbuda", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Argentina", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Armenia", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Australia", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Austria", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Azerbaijan", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Bahamas", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Bahrain", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Bangladesh", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]} -{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Barbados", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Belarus", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]} -{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Belgium", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Belize", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} -{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Benin", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Bhutan", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Bolivia, Plurinational State of", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]} -{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Bosnia and Herzegovina", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]} -{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Botswana", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Brazil", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Brunei Darussalam", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Bulgaria", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Burkina Faso", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Burundi", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Cabo Verde", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Cambodia", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Cameroon", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Canada", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "Central African Republic", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Chad", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Chile", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "China", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Colombia", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Comoros", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Congo", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "Congo, Democratic Republic of the", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Costa Rica", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Côte d'Ivoire", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} -{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Croatia", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Cuba", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]} -{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Cyprus", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Czechia", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Denmark", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Djibouti", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Dominica", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Dominican Republic", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Ecuador", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Egypt", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "El Salvador", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Equatorial Guinea", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Eritrea", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Estonia", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Eswatini", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Ethiopia", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Fiji", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Finland", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "France", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} -{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Gabon", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Gambia", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Georgia", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Germany", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]} -{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Ghana", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Greece", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Grenada", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Guatemala", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Guinea", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Guinea-Bissau", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Guyana", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} -{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Haiti", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Honduras", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]} -{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Hungary", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Iceland", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "India", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]} -{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Iran, Islamic Republic of", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Iraq", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ireland", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Israel", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]} -{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Italy", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Jamaica", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Japan", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]} -{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Jordan", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Kazakhstan", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]} -{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Kenya", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Kiribati", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "Korea, Democratic People's Republic of", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Korea, Republic of", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]} -{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Kuwait", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Kyrgyzstan", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Lao People's Democratic Republic", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Latvia", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Lebanon", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Lesotho", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Liberia", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Libya", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Liechtenstein", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Lithuania", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]} -{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Luxembourg", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Madagascar", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Malawi", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Malaysia", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]} -{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Maldives", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Mali", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Malta", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Marshall Islands", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Mauritania", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Mauritius", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Mexico", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Micronesia, Federated States of", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Moldova, Republic of", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Monaco", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Mongolia", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Montenegro", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Morocco", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]} -{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Mozambique", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Myanmar", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Namibia", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Nauru", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Nepal", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} -{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Netherlands", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} -{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "New Zealand", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} -{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Nicaragua", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Niger", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Nigeria", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "North Macedonia", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Norway", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Oman", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Pakistan", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]} -{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Palau", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Panama", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Papua New Guinea", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Paraguay", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Peru", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Philippines", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Poland", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Portugal", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Qatar", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Romania", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Russian Federation", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]} -{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Rwanda", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Saint Kitts and Nevis", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Saint Lucia", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Saint Vincent and the Grenadines", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Samoa", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]} -{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "San Marino", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} -{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Sao Tome and Principe", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Saudi Arabia", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Senegal", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Serbia", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Seychelles", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Sierra Leone", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Singapore", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Slovakia", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Slovenia", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Solomon Islands", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Somalia", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "South Africa", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "South Sudan", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Spain", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Sri Lanka", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Sudan", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Suriname", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} -{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Sweden", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]} -{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Switzerland", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Syrian Arab Republic", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Tajikistan", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Tanzania, United Republic of", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Thailand", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} -{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Timor-Leste", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Togo", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Tonga", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Trinidad and Tobago", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Tunisia", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Türkiye", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Turkmenistan", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Tuvalu", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]} -{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Uganda", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Ukraine", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "United Arab Emirates", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "United Kingdom of Great Britain and Northern Ireland", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "United States of America", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Uruguay", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Uzbekistan", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Vanuatu", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Venezuela, Bolivarian Republic of", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Viet Nam", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Yemen", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Zambia", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} +{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Afghanistan", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Albania", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Algeria", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Andorra", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Angola", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Antigua and Barbuda", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Argentina", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Armenia", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Australia", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Austria", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Azerbaijan", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Bahamas", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Bahrain", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Bangladesh", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Barbados", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Belarus", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]}, +{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Belgium", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Belize", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Benin", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Bhutan", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Bolivia, Plurinational State of", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Bosnia and Herzegovina", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]}, +{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Botswana", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Brazil", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Brunei Darussalam", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Bulgaria", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Burkina Faso", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Burundi", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Cabo Verde", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Cambodia", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Cameroon", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Canada", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "Central African Republic", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Chad", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Chile", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "China", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Colombia", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Comoros", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Congo", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "Congo, Democratic Republic of the", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Costa Rica", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Côte d'Ivoire", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Croatia", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Cuba", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Cyprus", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Czechia", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Denmark", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Djibouti", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Dominica", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Dominican Republic", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Ecuador", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Egypt", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "El Salvador", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Equatorial Guinea", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Eritrea", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Estonia", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Eswatini", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Ethiopia", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Fiji", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Finland", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "France", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Gabon", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Gambia", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Georgia", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Germany", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Ghana", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Greece", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Grenada", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Guatemala", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Guinea", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Guinea-Bissau", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Guyana", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Haiti", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Honduras", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Hungary", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Iceland", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "India", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Iran, Islamic Republic of", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Iraq", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ireland", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Israel", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Italy", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Jamaica", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Japan", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Jordan", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Kazakhstan", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Kenya", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Kiribati", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "Korea, Democratic People's Republic of", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Korea, Republic of", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Kuwait", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Kyrgyzstan", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Lao People's Democratic Republic", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Latvia", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Lebanon", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Lesotho", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Liberia", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Libya", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Liechtenstein", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Lithuania", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Luxembourg", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Madagascar", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Malawi", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Malaysia", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Maldives", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Mali", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Malta", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Marshall Islands", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Mauritania", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Mauritius", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Mexico", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Micronesia, Federated States of", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Moldova, Republic of", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Monaco", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Mongolia", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Montenegro", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Morocco", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Mozambique", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Myanmar", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Namibia", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Nauru", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Nepal", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Netherlands", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "New Zealand", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Nicaragua", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Niger", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Nigeria", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "North Macedonia", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Norway", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Oman", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Pakistan", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Palau", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Panama", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Papua New Guinea", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Paraguay", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Peru", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Philippines", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Poland", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Portugal", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Qatar", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Romania", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Russian Federation", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Rwanda", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Saint Kitts and Nevis", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Saint Lucia", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Saint Vincent and the Grenadines", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Samoa", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "San Marino", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Sao Tome and Principe", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Saudi Arabia", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Senegal", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Serbia", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Seychelles", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Sierra Leone", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Singapore", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Slovakia", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Slovenia", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Solomon Islands", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Somalia", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "South Africa", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "South Sudan", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Spain", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Sri Lanka", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Sudan", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Suriname", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Sweden", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]}, +{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Switzerland", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Syrian Arab Republic", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Tajikistan", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Tanzania, United Republic of", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Thailand", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Timor-Leste", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Togo", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Tonga", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Trinidad and Tobago", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Tunisia", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Türkiye", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Turkmenistan", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Tuvalu", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Uganda", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Ukraine", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "United Arab Emirates", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "United Kingdom of Great Britain and Northern Ireland", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "United States of America", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Uruguay", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Uzbekistan", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Vanuatu", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Venezuela, Bolivarian Republic of", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Viet Nam", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Yemen", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Zambia", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Zimbabwe", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} ]'''; const String _countriesRuJson = '''[ -{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Австралия", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Австрия", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Азербайджан", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Албания", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Алжир", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Ангола", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Андорра", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]} -{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Антигуа и Барбуда", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Аргентина", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Армения", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Афганистан", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Багамские Острова", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Бангладеш", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]} -{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Барбадос", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Бахрейн", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Белиз", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} -{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Беларусь", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]} -{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Бельгия", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Бенин", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Болгария", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Боливия", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]} -{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Босния и Герцеговина", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]} -{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Ботсвана", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Бразилия", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Бруней", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Буркина-Фасо", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Бурунди", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Бутан", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Вануату", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "Великобритания", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Венгрия", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Венесуэла", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Восточный Тимор", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Вьетнам", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Габон", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Гаити", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Гайана", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} -{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Гамбия", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Гана", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Гватемала", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Гвинея", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Гвинея-Бисау", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Германия", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]} -{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Гондурас", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]} -{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Гренада", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Греция", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Грузия", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Дания", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Джибути", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Доминика", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Доминиканская Республика", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "ДР Конго", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Египет", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Замбия", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Зимбабве", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Израиль", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]} -{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "Индия", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]} -{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Иордания", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Ирак", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Иран", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ирландия", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Исландия", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Испания", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Италия", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Йемен", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Кабо-Верде", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Казахстан", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]} -{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Камбоджа", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Камерун", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Канада", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Катар", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Кения", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Кипр", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Кыргызстан", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Кирибати", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "КНДР (Корейская Народно-Демократическая Республика)", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "Китай (Китайская Народная Республика)", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Колумбия", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Коморы", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Коста-Рика", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Кот-д’Ивуар", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} -{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Куба", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]} -{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Кувейт", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Лаос", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Латвия", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Лесото", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Либерия", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Ливан", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Ливия", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Литва", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]} -{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Лихтенштейн", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Люксембург", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Маврикий", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Мавритания", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Мадагаскар", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "Северная Македония", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Малави", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Малайзия", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]} -{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Мали", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Мальдивы", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Мальта", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Марокко", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]} -{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Маршалловы Острова", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Мексика", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Микронезия", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Мозамбик", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Молдова", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Монако", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Монголия", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Мьянма", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Намибия", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Науру", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Непал", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} -{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Нигер", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Нигерия", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Нидерланды", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} -{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Никарагуа", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "Новая Зеландия", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} -{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Норвегия", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "ОАЭ", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Оман", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Пакистан", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]} -{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Палау", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Панама", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Папуа — Новая Гвинея", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Парагвай", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Перу", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Польша", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Португалия", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Республика Конго", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Республика Корея", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]} -{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Россия", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]} -{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Руанда", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Румыния", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "Сальвадор", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Самоа", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]} -{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "Сан-Марино", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} -{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Сан-Томе и Принсипи", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Саудовская Аравия", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Сейшельские Острова", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Сенегал", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Сент-Винсент и Гренадины", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Сент-Китс и Невис", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Сент-Люсия", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Сербия", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Сингапур", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Сирия", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Словакия", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Словения", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Соломоновы Острова", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Сомали", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Судан", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Суринам", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]} -{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "США", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Сьерра-Леоне", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]} -{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Таджикистан", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Таиланд", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]} -{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Танзания", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Того", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Тонга", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Тринидад и Тобаго", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} -{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Тувалу", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]} -{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Тунис", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Туркменистан", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Турция", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Уганда", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Узбекистан", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Украина", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Уругвай", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Фиджи", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Филиппины", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Финляндия", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "Франция", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]} -{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Хорватия", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "ЦАР", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Чад", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Черногория", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Чехия", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Чили", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Швейцария", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Швеция", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]} -{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Шри-Ланка", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Эквадор", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Экваториальная Гвинея", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Эритрея", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Эсватини", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]} -{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Эстония", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]} -{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Эфиопия", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "ЮАР", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "Южный Судан", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]} -{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Ямайка", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]} +{"id": 36, "alpha2": "au", "alpha3": "aus", "name": "Австралия", "phoneCode": "+61", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 40, "alpha2": "at", "alpha3": "aut", "name": "Австрия", "phoneCode": "+43", "phoneDigits": 10, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 31, "alpha2": "az", "alpha3": "aze", "name": "Азербайджан", "phoneCode": "+994", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 8, "alpha2": "al", "alpha3": "alb", "name": "Албания", "phoneCode": "+355", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 12, "alpha2": "dz", "alpha3": "dza", "name": "Алжир", "phoneCode": "+213", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 24, "alpha2": "ao", "alpha3": "ago", "name": "Ангола", "phoneCode": "+244", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 20, "alpha2": "ad", "alpha3": "and", "name": "Андорра", "phoneCode": "+376", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 28, "alpha2": "ag", "alpha3": "atg", "name": "Антигуа и Барбуда", "phoneCode": "+1268", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 32, "alpha2": "ar", "alpha3": "arg", "name": "Аргентина", "phoneCode": "+54", "phoneDigits": 10, "phoneMask": "(##) ####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 51, "alpha2": "am", "alpha3": "arm", "name": "Армения", "phoneCode": "+374", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 4, "alpha2": "af", "alpha3": "afg", "name": "Афганистан", "phoneCode": "+93", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 44, "alpha2": "bs", "alpha3": "bhs", "name": "Багамские Острова", "phoneCode": "+1242", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 50, "alpha2": "bd", "alpha3": "bgd", "name": "Бангладеш", "phoneCode": "+880", "phoneDigits": 10, "phoneMask": "####-######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 52, "alpha2": "bb", "alpha3": "brb", "name": "Барбадос", "phoneCode": "+1246", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 48, "alpha2": "bh", "alpha3": "bhr", "name": "Бахрейн", "phoneCode": "+973", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 84, "alpha2": "bz", "alpha3": "blz", "name": "Белиз", "phoneCode": "+501", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 112, "alpha2": "by", "alpha3": "blr", "name": "Беларусь", "phoneCode": "+375", "phoneDigits": 9, "phoneMask": "## ###-##-##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", "-", "-", ""]}, +{"id": 56, "alpha2": "be", "alpha3": "bel", "name": "Бельгия", "phoneCode": "+32", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 204, "alpha2": "bj", "alpha3": "ben", "name": "Бенин", "phoneCode": "+229", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 100, "alpha2": "bg", "alpha3": "bgr", "name": "Болгария", "phoneCode": "+359", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 68, "alpha2": "bo", "alpha3": "bol", "name": "Боливия", "phoneCode": "+591", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 70, "alpha2": "ba", "alpha3": "bih", "name": "Босния и Герцеговина", "phoneCode": "+387", "phoneDigits": 8, "phoneMask": "## ###-###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", "-", ""]}, +{"id": 72, "alpha2": "bw", "alpha3": "bwa", "name": "Ботсвана", "phoneCode": "+267", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 76, "alpha2": "br", "alpha3": "bra", "name": "Бразилия", "phoneCode": "+55", "phoneDigits": 11, "phoneMask": "(##) #####-####", "phoneGroupSizes": [2, 5, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 96, "alpha2": "bn", "alpha3": "brn", "name": "Бруней", "phoneCode": "+673", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 854, "alpha2": "bf", "alpha3": "bfa", "name": "Буркина-Фасо", "phoneCode": "+226", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 108, "alpha2": "bi", "alpha3": "bdi", "name": "Бурунди", "phoneCode": "+257", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 64, "alpha2": "bt", "alpha3": "btn", "name": "Бутан", "phoneCode": "+975", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 548, "alpha2": "vu", "alpha3": "vut", "name": "Вануату", "phoneCode": "+678", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 826, "alpha2": "gb", "alpha3": "gbr", "name": "Великобритания", "phoneCode": "+44", "phoneDigits": 10, "phoneMask": "#### ######", "phoneGroupSizes": [4, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Венгрия", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 862, "alpha2": "ve", "alpha3": "ven", "name": "Венесуэла", "phoneCode": "+58", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 626, "alpha2": "tl", "alpha3": "tls", "name": "Восточный Тимор", "phoneCode": "+670", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 704, "alpha2": "vn", "alpha3": "vnm", "name": "Вьетнам", "phoneCode": "+84", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 266, "alpha2": "ga", "alpha3": "gab", "name": "Габон", "phoneCode": "+241", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 332, "alpha2": "ht", "alpha3": "hti", "name": "Гаити", "phoneCode": "+509", "phoneDigits": 8, "phoneMask": "## ## ####", "phoneGroupSizes": [2, 2, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 328, "alpha2": "gy", "alpha3": "guy", "name": "Гайана", "phoneCode": "+592", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 270, "alpha2": "gm", "alpha3": "gmb", "name": "Гамбия", "phoneCode": "+220", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 288, "alpha2": "gh", "alpha3": "gha", "name": "Гана", "phoneCode": "+233", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 320, "alpha2": "gt", "alpha3": "gtm", "name": "Гватемала", "phoneCode": "+502", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 324, "alpha2": "gn", "alpha3": "gin", "name": "Гвинея", "phoneCode": "+224", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 624, "alpha2": "gw", "alpha3": "gnb", "name": "Гвинея-Бисау", "phoneCode": "+245", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 276, "alpha2": "de", "alpha3": "deu", "name": "Германия", "phoneCode": "+49", "phoneDigits": 10, "phoneMask": "#### #######", "phoneGroupSizes": [4, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 340, "alpha2": "hn", "alpha3": "hnd", "name": "Гондурас", "phoneCode": "+504", "phoneDigits": 8, "phoneMask": "####-####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 308, "alpha2": "gd", "alpha3": "grd", "name": "Гренада", "phoneCode": "+1473", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 300, "alpha2": "gr", "alpha3": "grc", "name": "Греция", "phoneCode": "+30", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 268, "alpha2": "ge", "alpha3": "geo", "name": "Грузия", "phoneCode": "+995", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 208, "alpha2": "dk", "alpha3": "dnk", "name": "Дания", "phoneCode": "+45", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 262, "alpha2": "dj", "alpha3": "dji", "name": "Джибути", "phoneCode": "+253", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 212, "alpha2": "dm", "alpha3": "dma", "name": "Доминика", "phoneCode": "+1767", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 214, "alpha2": "do", "alpha3": "dom", "name": "Доминиканская Республика", "phoneCode": "+1809", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 180, "alpha2": "cd", "alpha3": "cod", "name": "ДР Конго", "phoneCode": "+243", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 818, "alpha2": "eg", "alpha3": "egy", "name": "Египет", "phoneCode": "+20", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 894, "alpha2": "zm", "alpha3": "zmb", "name": "Замбия", "phoneCode": "+260", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Зимбабве", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Израиль", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 356, "alpha2": "in", "alpha3": "ind", "name": "Индия", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Иордания", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Ирак", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Иран", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ирландия", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Исландия", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 724, "alpha2": "es", "alpha3": "esp", "name": "Испания", "phoneCode": "+34", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 380, "alpha2": "it", "alpha3": "ita", "name": "Италия", "phoneCode": "+39", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 887, "alpha2": "ye", "alpha3": "yem", "name": "Йемен", "phoneCode": "+967", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 132, "alpha2": "cv", "alpha3": "cpv", "name": "Кабо-Верде", "phoneCode": "+238", "phoneDigits": 7, "phoneMask": "### ## ##", "phoneGroupSizes": [3, 2, 2], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 398, "alpha2": "kz", "alpha3": "kaz", "name": "Казахстан", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 116, "alpha2": "kh", "alpha3": "khm", "name": "Камбоджа", "phoneCode": "+855", "phoneDigits": 9, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 120, "alpha2": "cm", "alpha3": "cmr", "name": "Камерун", "phoneCode": "+237", "phoneDigits": 9, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 124, "alpha2": "ca", "alpha3": "can", "name": "Канада", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 634, "alpha2": "qa", "alpha3": "qat", "name": "Катар", "phoneCode": "+974", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 404, "alpha2": "ke", "alpha3": "ken", "name": "Кения", "phoneCode": "+254", "phoneDigits": 9, "phoneMask": "### ######", "phoneGroupSizes": [3, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 196, "alpha2": "cy", "alpha3": "cyp", "name": "Кипр", "phoneCode": "+357", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 417, "alpha2": "kg", "alpha3": "kgz", "name": "Кыргызстан", "phoneCode": "+996", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 296, "alpha2": "ki", "alpha3": "kir", "name": "Кирибати", "phoneCode": "+686", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 408, "alpha2": "kp", "alpha3": "prk", "name": "КНДР (Корейская Народно-Демократическая Республика)", "phoneCode": "+850", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 156, "alpha2": "cn", "alpha3": "chn", "name": "Китай (Китайская Народная Республика)", "phoneCode": "+86", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 170, "alpha2": "co", "alpha3": "col", "name": "Колумбия", "phoneCode": "+57", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 174, "alpha2": "km", "alpha3": "com", "name": "Коморы", "phoneCode": "+269", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 188, "alpha2": "cr", "alpha3": "cri", "name": "Коста-Рика", "phoneCode": "+506", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 384, "alpha2": "ci", "alpha3": "civ", "name": "Кот-д’Ивуар", "phoneCode": "+225", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 192, "alpha2": "cu", "alpha3": "cub", "name": "Куба", "phoneCode": "+53", "phoneDigits": 8, "phoneMask": "########", "phoneGroupSizes": [8], "phoneGroupSeparators": ["", ""]}, +{"id": 414, "alpha2": "kw", "alpha3": "kwt", "name": "Кувейт", "phoneCode": "+965", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 418, "alpha2": "la", "alpha3": "lao", "name": "Лаос", "phoneCode": "+856", "phoneDigits": 9, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 428, "alpha2": "lv", "alpha3": "lva", "name": "Латвия", "phoneCode": "+371", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 426, "alpha2": "ls", "alpha3": "lso", "name": "Лесото", "phoneCode": "+266", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 430, "alpha2": "lr", "alpha3": "lbr", "name": "Либерия", "phoneCode": "+231", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 422, "alpha2": "lb", "alpha3": "lbn", "name": "Ливан", "phoneCode": "+961", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 434, "alpha2": "ly", "alpha3": "lby", "name": "Ливия", "phoneCode": "+218", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 440, "alpha2": "lt", "alpha3": "ltu", "name": "Литва", "phoneCode": "+370", "phoneDigits": 8, "phoneMask": "### #####", "phoneGroupSizes": [3, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 438, "alpha2": "li", "alpha3": "lie", "name": "Лихтенштейн", "phoneCode": "+423", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 442, "alpha2": "lu", "alpha3": "lux", "name": "Люксембург", "phoneCode": "+352", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 480, "alpha2": "mu", "alpha3": "mus", "name": "Маврикий", "phoneCode": "+230", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 478, "alpha2": "mr", "alpha3": "mrt", "name": "Мавритания", "phoneCode": "+222", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 450, "alpha2": "mg", "alpha3": "mdg", "name": "Мадагаскар", "phoneCode": "+261", "phoneDigits": 9, "phoneMask": "## ## ### ##", "phoneGroupSizes": [2, 2, 3, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 807, "alpha2": "mk", "alpha3": "mkd", "name": "Северная Македония", "phoneCode": "+389", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 454, "alpha2": "mw", "alpha3": "mwi", "name": "Малави", "phoneCode": "+265", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 458, "alpha2": "my", "alpha3": "mys", "name": "Малайзия", "phoneCode": "+60", "phoneDigits": 10, "phoneMask": "##-#### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 466, "alpha2": "ml", "alpha3": "mli", "name": "Мали", "phoneCode": "+223", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 462, "alpha2": "mv", "alpha3": "mdv", "name": "Мальдивы", "phoneCode": "+960", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 470, "alpha2": "mt", "alpha3": "mlt", "name": "Мальта", "phoneCode": "+356", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 504, "alpha2": "ma", "alpha3": "mar", "name": "Марокко", "phoneCode": "+212", "phoneDigits": 9, "phoneMask": "###-###-###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 584, "alpha2": "mh", "alpha3": "mhl", "name": "Маршалловы Острова", "phoneCode": "+692", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 484, "alpha2": "mx", "alpha3": "mex", "name": "Мексика", "phoneCode": "+52", "phoneDigits": 10, "phoneMask": "## #### ####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 583, "alpha2": "fm", "alpha3": "fsm", "name": "Микронезия", "phoneCode": "+691", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 508, "alpha2": "mz", "alpha3": "moz", "name": "Мозамбик", "phoneCode": "+258", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 498, "alpha2": "md", "alpha3": "mda", "name": "Молдова", "phoneCode": "+373", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 492, "alpha2": "mc", "alpha3": "mco", "name": "Монако", "phoneCode": "+377", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 496, "alpha2": "mn", "alpha3": "mng", "name": "Монголия", "phoneCode": "+976", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 104, "alpha2": "mm", "alpha3": "mmr", "name": "Мьянма", "phoneCode": "+95", "phoneDigits": 9, "phoneMask": "# ### ####", "phoneGroupSizes": [1, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 516, "alpha2": "na", "alpha3": "nam", "name": "Намибия", "phoneCode": "+264", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 520, "alpha2": "nr", "alpha3": "nru", "name": "Науру", "phoneCode": "+674", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 524, "alpha2": "np", "alpha3": "npl", "name": "Непал", "phoneCode": "+977", "phoneDigits": 10, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 562, "alpha2": "ne", "alpha3": "ner", "name": "Нигер", "phoneCode": "+227", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 566, "alpha2": "ng", "alpha3": "nga", "name": "Нигерия", "phoneCode": "+234", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 528, "alpha2": "nl", "alpha3": "nld", "name": "Нидерланды", "phoneCode": "+31", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 558, "alpha2": "ni", "alpha3": "nic", "name": "Никарагуа", "phoneCode": "+505", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 554, "alpha2": "nz", "alpha3": "nzl", "name": "Новая Зеландия", "phoneCode": "+64", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 578, "alpha2": "no", "alpha3": "nor", "name": "Норвегия", "phoneCode": "+47", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 784, "alpha2": "ae", "alpha3": "are", "name": "ОАЭ", "phoneCode": "+971", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 512, "alpha2": "om", "alpha3": "omn", "name": "Оман", "phoneCode": "+968", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 586, "alpha2": "pk", "alpha3": "pak", "name": "Пакистан", "phoneCode": "+92", "phoneDigits": 10, "phoneMask": "### #######", "phoneGroupSizes": [3, 7], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 585, "alpha2": "pw", "alpha3": "plw", "name": "Палау", "phoneCode": "+680", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 591, "alpha2": "pa", "alpha3": "pan", "name": "Панама", "phoneCode": "+507", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 598, "alpha2": "pg", "alpha3": "png", "name": "Папуа — Новая Гвинея", "phoneCode": "+675", "phoneDigits": 8, "phoneMask": "### ## ###", "phoneGroupSizes": [3, 2, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 600, "alpha2": "py", "alpha3": "pry", "name": "Парагвай", "phoneCode": "+595", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 604, "alpha2": "pe", "alpha3": "per", "name": "Перу", "phoneCode": "+51", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 616, "alpha2": "pl", "alpha3": "pol", "name": "Польша", "phoneCode": "+48", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 620, "alpha2": "pt", "alpha3": "prt", "name": "Португалия", "phoneCode": "+351", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 178, "alpha2": "cg", "alpha3": "cog", "name": "Республика Конго", "phoneCode": "+242", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 410, "alpha2": "kr", "alpha3": "kor", "name": "Республика Корея", "phoneCode": "+82", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, +{"id": 643, "alpha2": "ru", "alpha3": "rus", "name": "Россия", "phoneCode": "+7", "phoneDigits": 10, "phoneMask": "(###) ###-##-##", "phoneGroupSizes": [3, 3, 2, 2], "phoneGroupSeparators": ["(", ") ", "-", "-", ""]}, +{"id": 646, "alpha2": "rw", "alpha3": "rwa", "name": "Руанда", "phoneCode": "+250", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 642, "alpha2": "ro", "alpha3": "rou", "name": "Румыния", "phoneCode": "+40", "phoneDigits": 10, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 222, "alpha2": "sv", "alpha3": "slv", "name": "Сальвадор", "phoneCode": "+503", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 882, "alpha2": "ws", "alpha3": "wsm", "name": "Самоа", "phoneCode": "+685", "phoneDigits": 7, "phoneMask": "## #####", "phoneGroupSizes": [2, 5], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 674, "alpha2": "sm", "alpha3": "smr", "name": "Сан-Марино", "phoneCode": "+378", "phoneDigits": 10, "phoneMask": "## ## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 678, "alpha2": "st", "alpha3": "stp", "name": "Сан-Томе и Принсипи", "phoneCode": "+239", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 682, "alpha2": "sa", "alpha3": "sau", "name": "Саудовская Аравия", "phoneCode": "+966", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 690, "alpha2": "sc", "alpha3": "syc", "name": "Сейшельские Острова", "phoneCode": "+248", "phoneDigits": 7, "phoneMask": "# ### ###", "phoneGroupSizes": [1, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 686, "alpha2": "sn", "alpha3": "sen", "name": "Сенегал", "phoneCode": "+221", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 670, "alpha2": "vc", "alpha3": "vct", "name": "Сент-Винсент и Гренадины", "phoneCode": "+1784", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 659, "alpha2": "kn", "alpha3": "kna", "name": "Сент-Китс и Невис", "phoneCode": "+1869", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 662, "alpha2": "lc", "alpha3": "lca", "name": "Сент-Люсия", "phoneCode": "+1758", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 688, "alpha2": "rs", "alpha3": "srb", "name": "Сербия", "phoneCode": "+381", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 702, "alpha2": "sg", "alpha3": "sgp", "name": "Сингапур", "phoneCode": "+65", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 760, "alpha2": "sy", "alpha3": "syr", "name": "Сирия", "phoneCode": "+963", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 703, "alpha2": "sk", "alpha3": "svk", "name": "Словакия", "phoneCode": "+421", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 705, "alpha2": "si", "alpha3": "svn", "name": "Словения", "phoneCode": "+386", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 90, "alpha2": "sb", "alpha3": "slb", "name": "Соломоновы Острова", "phoneCode": "+677", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 706, "alpha2": "so", "alpha3": "som", "name": "Сомали", "phoneCode": "+252", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 729, "alpha2": "sd", "alpha3": "sdn", "name": "Судан", "phoneCode": "+249", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 740, "alpha2": "sr", "alpha3": "sur", "name": "Суринам", "phoneCode": "+597", "phoneDigits": 7, "phoneMask": "###-####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", "-", ""]}, +{"id": 840, "alpha2": "us", "alpha3": "usa", "name": "США", "phoneCode": "+1", "phoneDigits": 10, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 694, "alpha2": "sl", "alpha3": "sle", "name": "Сьерра-Леоне", "phoneCode": "+232", "phoneDigits": 8, "phoneMask": "## ######", "phoneGroupSizes": [2, 6], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 762, "alpha2": "tj", "alpha3": "tjk", "name": "Таджикистан", "phoneCode": "+992", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 764, "alpha2": "th", "alpha3": "tha", "name": "Таиланд", "phoneCode": "+66", "phoneDigits": 9, "phoneMask": "##-### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", " ", ""]}, +{"id": 834, "alpha2": "tz", "alpha3": "tza", "name": "Танзания", "phoneCode": "+255", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 768, "alpha2": "tg", "alpha3": "tgo", "name": "Того", "phoneCode": "+228", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 776, "alpha2": "to", "alpha3": "ton", "name": "Тонга", "phoneCode": "+676", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 780, "alpha2": "tt", "alpha3": "tto", "name": "Тринидад и Тобаго", "phoneCode": "+1868", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, +{"id": 798, "alpha2": "tv", "alpha3": "tuv", "name": "Тувалу", "phoneCode": "+688", "phoneDigits": 6, "phoneMask": "######", "phoneGroupSizes": [6], "phoneGroupSeparators": ["", ""]}, +{"id": 788, "alpha2": "tn", "alpha3": "tun", "name": "Тунис", "phoneCode": "+216", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 795, "alpha2": "tm", "alpha3": "tkm", "name": "Туркменистан", "phoneCode": "+993", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 792, "alpha2": "tr", "alpha3": "tur", "name": "Турция", "phoneCode": "+90", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 800, "alpha2": "ug", "alpha3": "uga", "name": "Уганда", "phoneCode": "+256", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 860, "alpha2": "uz", "alpha3": "uzb", "name": "Узбекистан", "phoneCode": "+998", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 804, "alpha2": "ua", "alpha3": "ukr", "name": "Украина", "phoneCode": "+380", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 858, "alpha2": "uy", "alpha3": "ury", "name": "Уругвай", "phoneCode": "+598", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 242, "alpha2": "fj", "alpha3": "fji", "name": "Фиджи", "phoneCode": "+679", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 608, "alpha2": "ph", "alpha3": "phl", "name": "Филиппины", "phoneCode": "+63", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 246, "alpha2": "fi", "alpha3": "fin", "name": "Финляндия", "phoneCode": "+358", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 250, "alpha2": "fr", "alpha3": "fra", "name": "Франция", "phoneCode": "+33", "phoneDigits": 9, "phoneMask": "# ## ## ## ##", "phoneGroupSizes": [1, 2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", " ", ""]}, +{"id": 191, "alpha2": "hr", "alpha3": "hrv", "name": "Хорватия", "phoneCode": "+385", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 140, "alpha2": "cf", "alpha3": "caf", "name": "ЦАР", "phoneCode": "+236", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 148, "alpha2": "td", "alpha3": "tcd", "name": "Чад", "phoneCode": "+235", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 499, "alpha2": "me", "alpha3": "mne", "name": "Черногория", "phoneCode": "+382", "phoneDigits": 8, "phoneMask": "## ### ###", "phoneGroupSizes": [2, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 203, "alpha2": "cz", "alpha3": "cze", "name": "Чехия", "phoneCode": "+420", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 152, "alpha2": "cl", "alpha3": "chl", "name": "Чили", "phoneCode": "+56", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 756, "alpha2": "ch", "alpha3": "che", "name": "Швейцария", "phoneCode": "+41", "phoneDigits": 9, "phoneMask": "## ### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 752, "alpha2": "se", "alpha3": "swe", "name": "Швеция", "phoneCode": "+46", "phoneDigits": 9, "phoneMask": "##-### ## ##", "phoneGroupSizes": [2, 3, 2, 2], "phoneGroupSeparators": ["", "-", " ", " ", ""]}, +{"id": 144, "alpha2": "lk", "alpha3": "lka", "name": "Шри-Ланка", "phoneCode": "+94", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 218, "alpha2": "ec", "alpha3": "ecu", "name": "Эквадор", "phoneCode": "+593", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 226, "alpha2": "gq", "alpha3": "gnq", "name": "Экваториальная Гвинея", "phoneCode": "+240", "phoneDigits": 9, "phoneMask": "### ## ## ##", "phoneGroupSizes": [3, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 232, "alpha2": "er", "alpha3": "eri", "name": "Эритрея", "phoneCode": "+291", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 748, "alpha2": "sz", "alpha3": "swz", "name": "Эсватини", "phoneCode": "+268", "phoneDigits": 8, "phoneMask": "## ## ## ##", "phoneGroupSizes": [2, 2, 2, 2], "phoneGroupSeparators": ["", " ", " ", " ", ""]}, +{"id": 233, "alpha2": "ee", "alpha3": "est", "name": "Эстония", "phoneCode": "+372", "phoneDigits": 8, "phoneMask": "#### ####", "phoneGroupSizes": [4, 4], "phoneGroupSeparators": ["", " ", ""]}, +{"id": 231, "alpha2": "et", "alpha3": "eth", "name": "Эфиопия", "phoneCode": "+251", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 710, "alpha2": "za", "alpha3": "zaf", "name": "ЮАР", "phoneCode": "+27", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 728, "alpha2": "ss", "alpha3": "ssd", "name": "Южный Судан", "phoneCode": "+211", "phoneDigits": 9, "phoneMask": "### ### ###", "phoneGroupSizes": [3, 3, 3], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 388, "alpha2": "jm", "alpha3": "jam", "name": "Ямайка", "phoneCode": "+1876", "phoneDigits": 7, "phoneMask": "(###) ###-####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["(", ") ", "-", ""]}, {"id": 392, "alpha2": "jp", "alpha3": "jpn", "name": "Япония", "phoneCode": "+81", "phoneDigits": 10, "phoneMask": "##-####-####", "phoneGroupSizes": [2, 4, 4], "phoneGroupSeparators": ["", "-", "-", ""]} ]'''; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index a7c2d62..3455d03 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -100,8 +100,7 @@ Packet unpackPacket(Uint8List packet) { var payloadBytes = packet.buffer.asUint8List(10, payloadLength); dynamic payload; - print(compFlag); - // Если payload пустой, ничего не делаем (так может быть при получении пинга) + if (payloadBytes.isNotEmpty) { if (compFlag != 0) { try { @@ -111,11 +110,10 @@ Packet unpackPacket(Uint8List packet) { ); } catch (_) { - // dart_lz4 не умеет block-формат, фолбэк на ручной декомпрессор try { payloadBytes = _lz4BlockDecompress(payloadBytes, _maxDecompressedSize); } catch (e) { - logger.e("Ошибка декомпрессии LZ4: $e", error: e); + logger.e("LZ4 decompression error: $e", error: e); } } } @@ -123,7 +121,9 @@ Packet unpackPacket(Uint8List packet) { try { payload = msgpack.deserialize(payloadBytes); } catch (e) { - logger.e("Ошибка десериализации MsgPack: $e", error: e); + if (payloadBytes.isNotEmpty) { + logger.e("MsgPack deserialization error: $e", error: e); + } } } diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 4320e5b..84ae57d 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -1,9 +1,8 @@ -import 'package:logger/logger.dart'; //нет это не опасный вредоносный вирус логер который украдёт ваш аккаунт Browl Starz +import 'package:logger/logger.dart'; +import 'package:flutter/foundation.dart'; final logger = Logger( - // устанавливает минимальный уровень логов. - // доступны уровни: all, trace, debug, info, warning, error, fatal, off - level: Level.all, + level: kReleaseMode ? Level.info : Level.all, printer: PrettyPrinter( methodCount: 0, errorMethodCount: 5, diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 0ea6a65..ec9eefd 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -3,11 +3,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import '../chats/chat_list_screen.dart'; +import 'password_2fa_screen.dart'; +import '../../../main.dart'; class CodeConfirmationScreen extends StatefulWidget { final String phoneNumber; + final String token; - const CodeConfirmationScreen({super.key, required this.phoneNumber}); + const CodeConfirmationScreen({ + super.key, + required this.phoneNumber, + required this.token, + }); @override State createState() => _CodeConfirmationScreenState(); @@ -53,16 +60,64 @@ class _CodeConfirmationScreenState extends State { void _resendCode() { if (_timerSeconds == 0) { _startTimer(); + // TODO: вызвать accountModule.resendCode print('Resending code to ${widget.phoneNumber}'); } } + Future _verifyCode() async { + if (_codeController.text.length != 6) return; + + try { + final result = await accountModule.verifyCode( + _codeController.text, + widget.token, + ); + + if (!mounted) return; + + if (result.requiresPassword) { + final trackId = result.challengeTrackId; + + if (trackId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ошибка: отсутствуют данные для 2FA')), + ); + return; + } + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => Password2FAScreen( + trackId: trackId, + hint: result.challengeHint, + ), + ), + ); + return; + } + + // Если 2FA не требуется, делаем login + final loginResult = await accountModule.login(); + + if (!mounted) return; + + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const ChatListScreen()), + (route) => false, + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ошибка: $e')), + ); + } + } + void _navigateToChats() { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (context) => const ChatListScreen()), - (route) => false, - ); + _verifyCode(); } @override @@ -118,11 +173,11 @@ class _CodeConfirmationScreenState extends State { autofillHints: const [AutofillHints.oneTimeCode], inputFormatters: [ FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(5), + LengthLimitingTextInputFormatter(6), ], onChanged: (value) { setState(() {}); - if (value.length == 5) { + if (value.length == 6) { _navigateToChats(); } }, @@ -131,9 +186,9 @@ class _CodeConfirmationScreenState extends State { ), GestureDetector( onTap: () => _focusNode.requestFocus(), - child: FittedBox( + child: FittedBox( child: Row( - children: List.generate(5, (index) { + children: List.generate(6, (index) { bool isFocused = _codeController.text.length == index && _focusNode.hasFocus; bool hasValue = _codeController.text.length > index; String char = hasValue ? _codeController.text[index] : ''; @@ -141,7 +196,7 @@ class _CodeConfirmationScreenState extends State { return Container( width: 44, height: 54, - margin: EdgeInsets.only(right: index == 4 ? 0 : 10), + margin: EdgeInsets.only(right: index == 5 ? 0 : 10), decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), @@ -198,11 +253,11 @@ class _CodeConfirmationScreenState extends State { children: [ FloatingActionButton( onPressed: () { - if (_codeController.text.length == 5) { + if (_codeController.text.length == 6) { _navigateToChats(); } }, - backgroundColor: _codeController.text.length == 5 + backgroundColor: _codeController.text.length == 6 ? cs.primaryContainer : cs.surfaceContainerHighest, elevation: 0, @@ -211,7 +266,7 @@ class _CodeConfirmationScreenState extends State { ), child: Icon( Icons.arrow_forward, - color: _codeController.text.length == 5 ? cs.onPrimaryContainer : cs.onSurfaceVariant, + color: _codeController.text.length == 6 ? cs.onPrimaryContainer : cs.onSurfaceVariant, ), ), ], diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 7a705d7..89b463b 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -9,6 +9,7 @@ import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; import 'spoff_redacted_screen.dart'; import '../../widgets/custom_notification.dart'; +import '../../../main.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -371,17 +372,30 @@ class _LoginScreenState extends State { ), ), TextButton( - onPressed: () { + onPressed: () async { Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CodeConfirmationScreen( - phoneNumber: - '${_selectedCountry.phoneCode} $formattedPhone', - ), - ), - ); + + final fullPhone = '${_selectedCountry.phoneCode}${_phoneController.text}'; + + try { + final result = await accountModule.requestCode(fullPhone); + + if (mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CodeConfirmationScreen( + phoneNumber: '${_selectedCountry.phoneCode} $formattedPhone', + token: result.token, + ), + ), + ); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка: $e'); + } + } }, child: Text( 'Готово', diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart new file mode 100644 index 0000000..faf6406 --- /dev/null +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../chats/chat_list_screen.dart'; +import '../../../main.dart'; + +class Password2FAScreen extends StatefulWidget { + final String trackId; + final String? hint; + + const Password2FAScreen({ + super.key, + required this.trackId, + this.hint, + }); + + @override + State createState() => _Password2FAScreenState(); +} + +class _Password2FAScreenState extends State { + final TextEditingController _passwordController = TextEditingController(); + bool _isPasswordVisible = false; + bool _isLoading = false; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _checkPassword() async { + if (_passwordController.text.isEmpty) return; + + setState(() { + _isLoading = true; + }); + + try { + await accountModule.checkPassword( + password: _passwordController.text, + trackId: widget.trackId, + ); + + if (!mounted) return; + + // После успешной 2FA делаем login + final loginResult = await accountModule.login(); + + if (!mounted) return; + + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (context) => const ChatListScreen()), + (route) => false, + ); + } catch (e) { + if (!mounted) return; + + setState(() { + _isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Неверный пароль: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant), + onPressed: () => Navigator.pop(context), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text( + 'Двухфакторная аутентификация', + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 22, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Text( + 'Введите пароль для завершения входа', + style: TextStyle( + color: cs.outline, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 1.4, + ), + ), + if (widget.hint != null) ...[ + const SizedBox(height: 8), + Text( + 'Подсказка: ${widget.hint}', + style: TextStyle( + color: cs.tertiary, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ], + const SizedBox(height: 24), + TextField( + controller: _passwordController, + obscureText: !_isPasswordVisible, + autofocus: true, + enabled: !_isLoading, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + suffixIcon: IconButton( + icon: Icon( + _isPasswordVisible ? Icons.visibility_off : Icons.visibility, + color: cs.onSurfaceVariant, + ), + onPressed: () { + setState(() { + _isPasswordVisible = !_isPasswordVisible; + }); + }, + ), + ), + onSubmitted: (_) => _checkPassword(), + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + onPressed: _isLoading ? null : _checkPassword, + backgroundColor: _passwordController.text.isNotEmpty + ? cs.primaryContainer + : cs.surfaceContainerHighest, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + child: _isLoading + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimaryContainer, + ), + ) + : Icon( + Icons.arrow_forward, + color: _passwordController.text.isNotEmpty + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index cf03657..a50de9b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,13 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'backend/api.dart'; +import 'backend/modules/account.dart'; import 'core/storage/app_database.dart'; +import 'frontend/screens/auth/login_screen.dart'; final api = Api(); +final accountModule = AccountModule(api); void main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/pubspec.lock b/pubspec.lock index 931689c..8948ca6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -61,10 +61,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" dart_lz4: dependency: "direct main" description: @@ -77,10 +77,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "12.3.0" + version: "12.4.0" device_info_plus_platform_interface: dependency: transitive description: @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c" + url: "https://pub.dev" + source: hosted + version: "1.8.1" equatable: dependency: transitive description: @@ -191,10 +199,10 @@ packages: dependency: "direct main" description: name: flutter_timezone - sha256: "978192f2f9ea6d019a4de4f0211d76a9af955ca24865828fa98ca4e20cf0cb3c" + sha256: e8d63f50f2806a3a71a08697286a0369e1d8f0902961327810459871c0bb01c2 url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "5.0.2" flutter_web_plugins: dependency: transitive description: flutter @@ -208,6 +216,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" hooks: dependency: transitive description: @@ -268,10 +284,10 @@ packages: dependency: "direct main" description: name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.7.0" logging: dependency: transitive description: @@ -284,26 +300,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: name: material_symbols_icons - sha256: c62b15f2b3de98d72cbff0148812f5ef5159f05e61fc9f9a089ec2bb234df082 + sha256: "10a74aaa9e566c92f8aa14809d2dd78156fb93743348ebffec0345c38eb35706" url: "https://pub.dev" source: hosted - version: "4.2906.0" + version: "4.2928.1" meta: dependency: transitive description: @@ -329,19 +345,6 @@ packages: source: hosted version: "0.17.6" objective_c: - sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" - url: "https://pub.dev" - source: hosted - version: "0.17.5" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" - path: dependency: transitive description: name: objective_c @@ -429,6 +432,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -542,15 +601,7 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" - url: "https://pub.dev" - source: hosted - version: "0.7.10" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted version: "0.7.7" From a0a4d7dd6b8e52f4709cea5a900483cb14b42ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Fri, 3 Apr 2026 19:35:45 +0700 Subject: [PATCH 021/323] =?UTF-8?q?=D1=8F=20=D0=BA=D0=B0=D0=BA=20=D0=B1?= =?UTF-8?q?=D1=8B=20=D0=B5=D1=89=D0=B5=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B0=D0=BB,=20=D1=8D=D1=82=D0=BE=20=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=BC=D0=B5=D0=B6=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=83=D1=88=20?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D0=B5=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 + lib/backend/api.dart | 110 +++---- lib/backend/modules/account.dart | 50 ++-- lib/backend/modules/chats.dart | 138 ++++++--- lib/backend/modules/contacts.dart | 56 ++++ lib/core/storage/app_database.dart | 112 +++++-- lib/core/storage/token_storage.dart | 37 ++- lib/core/transport/dispatcher.dart | 9 +- lib/core/transport/sender.dart | 5 +- .../auth/code_confirmation_screen.dart | 279 ++++++++++++------ lib/frontend/screens/auth/login_screen.dart | 65 +++- .../screens/auth/password_2fa_screen.dart | 18 +- .../screens/chats/chat_list_screen.dart | 162 ++++++---- lib/frontend/screens/chats/chat_screen.dart | 34 ++- .../screens/profile/settings_tab.dart | 235 ++++++++++++--- lib/main.dart | 73 ++++- pubspec.lock | 16 +- 17 files changed, 987 insertions(+), 417 deletions(-) create mode 100644 AGENTS.md create mode 100644 lib/backend/modules/contacts.dart diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c894244 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +Не оставляй комментарии в код +Старайся писать чистый код +Лучше качество чем количество +Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно. +ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст') diff --git a/lib/backend/api.dart b/lib/backend/api.dart index f014ad8..59efde3 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -16,12 +16,7 @@ import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/timezone.dart' as tz; import 'package:flutter_timezone/flutter_timezone.dart'; -enum SessionState { - disconnected, - connecting, - connected, - online -} +enum SessionState { disconnected, connecting, connected, online } /// Клиент API. /// @@ -34,6 +29,9 @@ class Api { SessionState _sessionState = SessionState.disconnected; final _stateController = StreamController.broadcast(); + Map? _userAgent; + + Map? get userAgent => _userAgent; Stream get stateStream => _stateController.stream; SessionState get state => _sessionState; @@ -98,86 +96,77 @@ class Api { _setSessionState(SessionState.disconnected); } - /// Отправляет хэндшейк (opcode 6). Future sendHandshake() async { - DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + final deviceInfo = DeviceInfoPlugin(); - // Если платформа Linux или Windows, то ставим DESKTOP, если нет, то проверяем на Android или IOS; - String deviceType = (Platform.isLinux || Platform.isWindows) ? "DESKTOP" : (Platform.isAndroid) ? "ANDROID" : "IOS"; - String osVersion = ""; - String deviceName = "Unknown"; - String architecture = "arm64"; - - tz.initializeTimeZones(); - - final now = DateTime.now(); - String timezone = "Europe/Moscow"; + final deviceType = (Platform.isLinux || Platform.isWindows) + ? 'DESKTOP' + : (Platform.isAndroid) + ? 'ANDROID' + : 'IOS'; + String osVersion = ''; + String deviceName = 'Unknown'; + String architecture = 'arm64'; tz.initializeTimeZones(); final timeZoneName = await FlutterTimezone.getLocalTimezone(); - timezone = timeZoneName.identifier; + final timezone = timeZoneName.identifier; - // На каждой платформе свое инфо, поэтому делаем такую проверку if (Platform.isLinux) { - LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo; - + final linuxInfo = await deviceInfo.linuxInfo; osVersion = linuxInfo.name; - // Platform.version содержит в себе что-то такое - // 3.11.1 (stable) (Tue Feb 24 00:03:07 2026 -0800) on "linux_x64" - // Поэтому мы находим '_', прибавляем к его индексу 1 и берем символы до length - 1 - architecture = Platform.version.substring(Platform.version.indexOf('_') + 1, Platform.version.length - 1); + architecture = Platform.version.substring( + Platform.version.indexOf('_') + 1, + Platform.version.length - 1, + ); } else if (Platform.isIOS) { - IosDeviceInfo iosInfo = await deviceInfo.iosInfo; - + final iosInfo = await deviceInfo.iosInfo; osVersion = iosInfo.systemVersion; deviceName = iosInfo.utsname.machine; } else if (Platform.isAndroid) { - AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; - - osVersion = "Android ${androidInfo.version.release}"; - deviceName = "${androidInfo.manufacturer} ${androidInfo.model}"; - architecture = androidInfo.supportedAbis.first; + final androidInfo = await deviceInfo.androidInfo; + osVersion = 'Android ${androidInfo.version.release}'; + deviceName = '${androidInfo.manufacturer} ${androidInfo.model}'; + architecture = androidInfo.supportedAbis.first; } else if (Platform.isWindows) { - WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; - + final windowsInfo = await deviceInfo.windowsInfo; osVersion = windowsInfo.productName; - architecture = Platform.version.substring(Platform.version.indexOf('_') + 1, Platform.version.length - 1); + architecture = Platform.version.substring( + Platform.version.indexOf('_') + 1, + Platform.version.length - 1, + ); } - - print(deviceType); + + _userAgent = { + 'deviceType': deviceType, + 'locale': 'ru', + 'deviceLocale': Platform.localeName.substring(0, 2), + 'osVersion': osVersion, + 'deviceName': deviceName, + 'appVersion': '26.8.1', + 'screen': '1920x1080', + 'timezone': timezone, + 'pushDeviceType': 'GCM', + 'arch': architecture, + 'buildNumber': 6606, + }; + final payload = { 'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000', 'clientSessionId': 42, 'deviceId': 'a1b2c3d4e5f6a7b8', - 'userAgent': { - 'deviceType': deviceType, - // Первые два символа из locale это и есть нужный нам аргумент - 'locale': "ru", - 'deviceLocale': Platform.localeName.substring(0, 2), - 'osVersion': osVersion, - 'deviceName': deviceName, - 'appVersion': '26.8.1', - 'screen': '1920x1080', - // 'screen': screenSize.width + 'x' + screenSize.height, - 'timezone': timezone, - 'pushDeviceType': 'GCM', - 'arch': architecture, - 'buildNumber': 6606, - }, + 'userAgent': _userAgent, }; - print(payload); - print(Platform.version); return sendRequest(Opcode.sessionInit, payload); } /// Отправляет запрос и ждёт ответ от сервера. - Future sendRequest( - int opcode, - Map payload, - ) { + Future sendRequest(int opcode, Map payload) { final seq = _sender.send(_connection, opcode, payload); - return _dispatcher.registerPending(seq).timeout( + return _dispatcher + .registerPending(seq) + .timeout( ServerConfig.requestTimeout, onTimeout: () => throw TimeoutException('${Opcode.name(opcode)} таймаут'), @@ -203,7 +192,6 @@ class Api { // Внутрянка - void _setSessionState(SessionState state) { if (_sessionState == state) return; _sessionState = state; diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 57ce448..b8ff3c3 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -5,6 +5,14 @@ import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import 'chats.dart'; +import 'contacts.dart'; + +class ServerException implements Exception { + final String message; + const ServerException(this.message); + @override + String toString() => message; +} enum AuthRequestType { startAuth('START_AUTH'), @@ -115,11 +123,13 @@ class LoginResult { final ProfileData profile; final String? updatedToken; final int serverTime; + final Map raw; const LoginResult({ required this.profile, required this.updatedToken, required this.serverTime, + required this.raw, }); } @@ -131,14 +141,12 @@ class AccountModule { Future requestCode( String phone, { String language = 'ru', - }) => - _requestCodeInternal(phone, AuthRequestType.startAuth, language); + }) => _requestCodeInternal(phone, AuthRequestType.startAuth, language); Future resendCode( String phone, { String language = 'ru', - }) => - _requestCodeInternal(phone, AuthRequestType.resend, language); + }) => _requestCodeInternal(phone, AuthRequestType.resend, language); Future verifyCode(String code, String token) async { _ensureOnline(); @@ -157,7 +165,9 @@ class AccountModule { final data = packet.payload; if (data is! Map) { - throw Exception('verifyCode: неожиданный тип payload: ${data.runtimeType}'); + throw Exception( + 'verifyCode: неожиданный тип payload: ${data.runtimeType}', + ); } final result = VerifyCodeResult(payload: data.cast()); @@ -181,7 +191,8 @@ class AccountModule { }) async { _ensureOnline(); - final resolvedAccountId = accountId ?? await TokenStorage.getActiveAccountId(); + final resolvedAccountId = + accountId ?? await TokenStorage.getActiveAccountId(); if (resolvedAccountId == null) { throw StateError('login: нет активного аккаунта'); } @@ -191,13 +202,7 @@ class AccountModule { throw StateError('login: нет токена для аккаунта $resolvedAccountId'); } - final resolvedSyncParams = - syncParams ?? await LoginSyncParams.fromDatabase(resolvedAccountId); - - final requestPayload = _buildLoginPayload(authToken, resolvedSyncParams); - - logger.i('LOGIN opcode=${Opcode.login} ' - 'account=$resolvedAccountId warm=${resolvedSyncParams != null}'); + final requestPayload = _buildLoginPayload(authToken, syncParams); final packet = await _api.sendRequest(Opcode.login, requestPayload); @@ -262,7 +267,9 @@ class AccountModule { final data = packet.payload; if (data is! Map) { - throw Exception('checkPassword: неожиданный тип payload: ${data.runtimeType}'); + throw Exception( + 'checkPassword: неожиданный тип payload: ${data.runtimeType}', + ); } if (data['error'] != null) { @@ -310,7 +317,8 @@ class AccountModule { ) { final payload = { 'token': token, - 'interactive': true + 'interactive': true, + if (_api.userAgent != null) 'userAgent': _api.userAgent, }; if (sync != null) { @@ -342,7 +350,6 @@ class AccountModule { final updatedToken = data['token'] as String?; if (updatedToken != null) { await TokenStorage.saveToken(updatedToken, accountId); - logger.i('Обновлённый токен аккаунта $accountId сохранён'); } final profileMap = data['profile']; @@ -356,15 +363,16 @@ class AccountModule { final profile = ProfileData.fromServerMap(contact.cast()); await AppDatabase.saveProfile(profile); await AppDatabase.setActiveAccount(profile.id); - logger.i('Профиль сохранён: id=${profile.id}, name=${profile.firstName}'); await _saveSyncState(data, serverTime, profile.id); + await ContactsModule.syncFromLoginPayload(data, profile.id); await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id); return LoginResult( profile: profile, updatedToken: updatedToken, serverTime: serverTime, + raw: data, ); } @@ -415,7 +423,9 @@ class AccountModule { final data = packet.payload; if (data is! Map) { - throw Exception('requestCode: неожиданный тип payload: ${data.runtimeType}'); + throw Exception( + 'requestCode: неожиданный тип payload: ${data.runtimeType}', + ); } final token = data['token']; @@ -439,8 +449,8 @@ class AccountModule { if (packet.isError) { final errMsg = packet.payload is Map ? (packet.payload as Map)['message'] ?? packet.payload.toString() - : packet.payload?.toString() ?? 'unknown error'; - throw Exception('$method: ошибка от сервера — $errMsg'); + : packet.payload?.toString() ?? 'Неизвестная ошибка'; + throw ServerException(errMsg.toString()); } } } diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index d9c7d91..33413d3 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -13,6 +13,10 @@ class CachedChat { final int unreadCount; final int lastEventTime; final int cachedAt; + final int? favIndex; + final int dontDisturbUntil; + final bool isOnline; + final int seenTime; const CachedChat({ required this.id, @@ -27,37 +31,49 @@ class CachedChat { required this.unreadCount, required this.lastEventTime, required this.cachedAt, + this.favIndex, + required this.dontDisturbUntil, + required this.isOnline, + required this.seenTime, }); factory CachedChat.fromDbRow(Map row) => CachedChat( - id: row['id'] as int, - accountId: row['account_id'] as int, - type: row['type'] as String, - title: row['title'] as String?, - iconUrl: row['icon_url'] as String?, - lastMsgId: row['last_msg_id'] as int?, - lastMsgTime: row['last_msg_time'] as int?, - lastMsgText: row['last_msg_text'] as String?, - lastMsgSenderId: row['last_msg_sender'] as int?, - unreadCount: row['unread_count'] as int, - lastEventTime: row['last_event_time'] as int, - cachedAt: row['cached_at'] as int, - ); + id: row['id'] as int, + accountId: row['account_id'] as int, + type: row['type'] as String, + title: row['title'] as String?, + iconUrl: row['icon_url'] as String?, + lastMsgId: row['last_msg_id'] as int?, + lastMsgTime: row['last_msg_time'] as int?, + lastMsgText: row['last_msg_text'] as String?, + lastMsgSenderId: row['last_msg_sender'] as int?, + unreadCount: row['unread_count'] as int, + lastEventTime: row['last_event_time'] as int, + cachedAt: row['cached_at'] as int, + favIndex: row['fav_index'] as int?, + dontDisturbUntil: row['dont_disturb_until'] as int, + isOnline: (row['is_online'] as int) == 1, + seenTime: row['seen_time'] as int, + ); Map toDbRow() => { - 'id': id, - 'account_id': accountId, - 'type': type, - 'title': title, - 'icon_url': iconUrl, - 'last_msg_id': lastMsgId, - 'last_msg_time': lastMsgTime, - 'last_msg_text': lastMsgText, - 'last_msg_sender': lastMsgSenderId, - 'unread_count': unreadCount, - 'last_event_time': lastEventTime, - 'cached_at': cachedAt, - }; + 'id': id, + 'account_id': accountId, + 'type': type, + 'title': title, + 'icon_url': iconUrl, + 'last_msg_id': lastMsgId, + 'last_msg_time': lastMsgTime, + 'last_msg_text': lastMsgText, + 'last_msg_sender': lastMsgSenderId, + 'unread_count': unreadCount, + 'last_event_time': lastEventTime, + 'cached_at': cachedAt, + 'fav_index': favIndex, + 'dont_disturb_until': dontDisturbUntil, + 'is_online': isOnline ? 1 : 0, + 'seen_time': seenTime, + }; } class ChatsModule { @@ -75,23 +91,35 @@ class ChatsModule { if (chats is! List || chats.isEmpty) return; final contactsMap = _buildContactsMap(data['contacts']); + // Config contains mute setup and fav indexes: config -> chats -> id + final configMap = data['config'] is Map ? data['config'] as Map : {}; + final chatsConfig = configMap['chats'] is Map + ? configMap['chats'] as Map + : {}; + // Presence for online statuses + final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; final cachedAt = DateTime.now().millisecondsSinceEpoch; final existingRows = await AppDatabase.loadChats(accountId); final existing = { - for (final row in existingRows) row['id'] as int: CachedChat.fromDbRow(row), + for (final row in existingRows) + row['id'] as int: CachedChat.fromDbRow(row), }; final rows = chats .whereType() - .map((c) => _parseChat( - c.cast(), - accountId, - currentUserId, - contactsMap, - existing, - cachedAt, - )) + .map( + (c) => _parseChat( + c.cast(), + accountId, + currentUserId, + contactsMap, + chatsConfig, + presenceMap, + existing, + cachedAt, + ), + ) .whereType() .map((c) => c.toDbRow()) .toList(); @@ -109,7 +137,7 @@ class ChatsModule { static Future clearCache(int accountId) => AppDatabase.clearChatsCache(accountId); - // internal + // internal static Map> _buildContactsMap(dynamic contacts) { if (contacts is! List) return {}; @@ -126,6 +154,8 @@ class ChatsModule { int accountId, int currentUserId, Map> contactsMap, + Map chatsConfig, + Map presenceMap, Map existing, int cachedAt, ) { @@ -133,19 +163,19 @@ class ChatsModule { if (id is! int) return null; final type = (chat['type'] as String?) ?? 'DIALOG'; + int? otherId; String? title; String? iconUrl; if (type == 'DIALOG') { - final otherId = _otherParticipantId(chat['participants'], currentUserId); + otherId = _otherParticipantId(chat['participants'], currentUserId); final contact = otherId != null ? contactsMap[otherId] : null; if (contact != null) { title = _nameFromContact(contact); iconUrl = contact['baseUrl'] as String?; } else { - // Warm start: контакты не пришли — берём из кэша title = existing[id]?.title; iconUrl = existing[id]?.iconUrl; } @@ -167,6 +197,24 @@ class ChatsModule { lastMsgSenderId = lastMsg['sender'] as int?; } + final config = chatsConfig[id.toString()] ?? chatsConfig[id]; + int? favIndex; + int dontDisturbUntil = 0; + if (config is Map) { + favIndex = config['favIndex'] as int?; + dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; + } + + int seenTime = 0; + bool isOnline = false; + if (type == 'DIALOG' && otherId != null) { + final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; + if (presence is Map) { + seenTime = (presence['seen'] as int?) ?? 0; + isOnline = (presence['status'] as int?) == 1; + } + } + return CachedChat( id: id, accountId: accountId, @@ -180,6 +228,10 @@ class ChatsModule { unreadCount: (chat['newMessages'] as int?) ?? 0, lastEventTime: (chat['lastEventTime'] as int?) ?? 0, cachedAt: cachedAt, + favIndex: favIndex, + dontDisturbUntil: dontDisturbUntil, + isOnline: isOnline, + seenTime: seenTime, ); } @@ -195,10 +247,12 @@ class ChatsModule { static String? _nameFromContact(Map contact) { final names = contact['names']; if (names is! List || names.isEmpty) return null; - final name = names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.first, - ) as Map; + final name = + names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) + as Map; return name['name'] as String?; } } diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart new file mode 100644 index 0000000..7d6614a --- /dev/null +++ b/lib/backend/modules/contacts.dart @@ -0,0 +1,56 @@ +import '../../core/storage/app_database.dart'; + +class ContactsModule { + static Future syncFromLoginPayload( + Map data, + int accountId, + ) async { + final contacts = data['contacts']; + if (contacts is! List || contacts.isEmpty) return; + + final rows = contacts + .whereType() + .map((c) => _parseContact(c.cast(), accountId)) + .whereType>() + .toList(); + + if (rows.isNotEmpty) { + await AppDatabase.saveContacts(rows); + } + } + + static Map? _parseContact( + Map contact, + int accountId, + ) { + final id = contact['id']; + if (id is! int) return null; + + String firstName = ''; + String? lastName; + + final names = contact['names']; + if (names is List && names.isNotEmpty) { + final name = + names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) + as Map; + firstName = (name['firstName'] as String?) ?? ''; + lastName = name['lastName'] as String?; + } + + return { + 'id': id, + 'account_id': accountId, + 'first_name': firstName, + 'last_name': lastName, + 'phone': (contact['phone'] as int?) ?? 0, + 'photo_id': contact['photoId'] as int?, + 'base_url': contact['baseUrl'] as String?, + 'base_raw_url': contact['baseRawUrl'] as String?, + 'update_time': (contact['updateTime'] as int?) ?? 0, + }; + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 3c0409a..7f5af5f 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -35,10 +35,12 @@ class ProfileData { String? lastName; if (names is List && names.isNotEmpty) { - final name = names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.first, - ) as Map; + final name = + names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) + as Map; firstName = (name['firstName'] as String?) ?? ''; lastName = name['lastName'] as String?; } @@ -73,17 +75,17 @@ class ProfileData { } Map toDbRow() => { - 'id': id, - 'first_name': firstName, - 'last_name': lastName, - 'phone': phone, - 'photo_id': photoId, - 'base_url': baseUrl, - 'base_raw_url': baseRawUrl, - 'country': country, - 'account_status': accountStatus, - 'update_time': updateTime, - }; + 'id': id, + 'first_name': firstName, + 'last_name': lastName, + 'phone': phone, + 'photo_id': photoId, + 'base_url': baseUrl, + 'base_raw_url': baseRawUrl, + 'country': country, + 'account_status': accountStatus, + 'update_time': updateTime, + }; } abstract class SyncKey { @@ -118,7 +120,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 3, + version: 5, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -132,6 +134,13 @@ class AppDatabase { if (oldVersion < 3) { await db.execute(_chatsCacheSchema); } + if (oldVersion < 4) { + await db.execute(_contactsSchema); + } + if (oldVersion < 5) { + await db.execute('DROP TABLE IF EXISTS chats_cache'); + await db.execute(_chatsCacheSchema); + } }, ); } @@ -154,8 +163,23 @@ class AppDatabase { '''); await db.execute(_syncStateSchema); await db.execute(_chatsCacheSchema); + await db.execute(_contactsSchema); } + static const _contactsSchema = ''' + CREATE TABLE contacts ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + first_name TEXT NOT NULL, + last_name TEXT, + phone INTEGER NOT NULL, + photo_id INTEGER, + base_url TEXT, + base_raw_url TEXT, + update_time INTEGER NOT NULL DEFAULT 0 + ) + '''; + static const _syncStateSchema = ''' CREATE TABLE sync_state ( account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, @@ -179,6 +203,10 @@ class AppDatabase { unread_count INTEGER NOT NULL DEFAULT 0, last_event_time INTEGER NOT NULL DEFAULT 0, cached_at INTEGER NOT NULL, + fav_index INTEGER, + dont_disturb_until INTEGER NOT NULL DEFAULT 0, + is_online INTEGER NOT NULL DEFAULT 0, + seen_time INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (id, account_id) ) '''; @@ -212,11 +240,7 @@ class AppDatabase { static Future loadActiveProfile() async { final db = await _instance; - final rows = await db.query( - 'profile', - where: 'is_active = 1', - limit: 1, - ); + final rows = await db.query('profile', where: 'is_active = 1', limit: 1); if (rows.isEmpty) return null; return ProfileData.fromDbRow(rows.first); } @@ -245,11 +269,11 @@ class AppDatabase { String value, ) async { final db = await _instance; - await db.insert( - 'sync_state', - {'account_id': accountId, 'key': key, 'value': value}, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.insert('sync_state', { + 'account_id': accountId, + 'key': key, + 'value': value, + }, conflictAlgorithm: ConflictAlgorithm.replace); } static Future getSyncValue(int accountId, String key) async { @@ -281,13 +305,17 @@ class AppDatabase { _db = null; } - // Chats cache + // Chats cache static Future saveChats(List> rows) async { final db = await _instance; final batch = db.batch(); for (final row in rows) { - batch.insert('chats_cache', row, conflictAlgorithm: ConflictAlgorithm.replace); + batch.insert( + 'chats_cache', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); } await batch.commit(noResult: true); } @@ -304,6 +332,32 @@ class AppDatabase { static Future clearChatsCache(int accountId) async { final db = await _instance; - await db.delete('chats_cache', where: 'account_id = ?', whereArgs: [accountId]); + await db.delete( + 'chats_cache', + where: 'account_id = ?', + whereArgs: [accountId], + ); + } + + static Future saveContacts(List> rows) async { + final db = await _instance; + final batch = db.batch(); + for (final row in rows) { + batch.insert( + 'contacts', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + + static Future>> loadContacts(int accountId) async { + final db = await _instance; + return db.query( + 'contacts', + where: 'account_id = ?', + whereArgs: [accountId], + ); } } diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 657ae14..75ab4bb 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -1,27 +1,32 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class TokenStorage { static const _tokenPrefix = 'auth_token_'; static const _activeAccountKey = 'active_account_id'; - static const _storage = FlutterSecureStorage( - aOptions: AndroidOptions(encryptedSharedPreferences: true), - ); + static Future saveToken(String token, int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('$_tokenPrefix$accountId', token); + } - static Future saveToken(String token, int accountId) => - _storage.write(key: '$_tokenPrefix$accountId', value: token); + static Future readToken(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('$_tokenPrefix$accountId'); + } - static Future readToken(int accountId) => - _storage.read(key: '$_tokenPrefix$accountId'); + static Future deleteToken(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('$_tokenPrefix$accountId'); + } - static Future deleteToken(int accountId) => - _storage.delete(key: '$_tokenPrefix$accountId'); - - static Future setActiveAccount(int accountId) => - _storage.write(key: _activeAccountKey, value: accountId.toString()); + static Future setActiveAccount(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_activeAccountKey, accountId.toString()); + } static Future getActiveAccountId() async { - final val = await _storage.read(key: _activeAccountKey); + final prefs = await SharedPreferences.getInstance(); + final val = prefs.getString(_activeAccountKey); return val != null ? int.tryParse(val) : null; } @@ -31,12 +36,12 @@ class TokenStorage { return readToken(id); } - /// Удаляет токен аккаунта и, если он был активным, сбрасывает активный аккаунт. static Future deleteAccount(int accountId) async { await deleteToken(accountId); final activeId = await getActiveAccountId(); if (activeId == accountId) { - await _storage.delete(key: _activeAccountKey); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_activeAccountKey); } } } diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index efeec15..103f033 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -53,8 +53,9 @@ class PacketDispatcher { if (packet.cmd == CmdType.ok || packet.cmd == CmdType.error || packet.cmd == CmdType.notFound) { - final status = packet.isOk ? 'OK' : packet.isError ? 'ERR' : 'NOT_FOUND'; - logger.i('<= [$tag] seq=${packet.seq} $status\n payload: ${packet.payload}'); + logger.i( + '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}', + ); final completer = _pendingRequests.remove(packet.seq); _requestTimestamps.remove(packet.seq); @@ -72,7 +73,9 @@ class PacketDispatcher { completer.complete(packet); } } else if (packet.isPush) { - logger.i('<= push [$tag] ${packet.payload}'); + logger.i( + '<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}', + ); _pushHandlers[packet.opcode]?.call(packet); _pushController.add(packet); } diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index 31a5c2c..8ef642f 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -1,5 +1,4 @@ import '../protocol/packet.dart'; -import '../protocol/opcode_map.dart'; import '../utils/logger.dart'; import 'connection.dart'; @@ -19,7 +18,9 @@ class PacketSender { final seq = _nextSeq(); final data = packPacket(opcode, payload, seq: seq); connection.write(data); - logger.i('=> [${Opcode.name(opcode)}] seq=$seq\n payload: $payload'); + logger.i( + '=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: $payload}', + ); return seq; } } diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index ec9eefd..f86f656 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -5,13 +5,14 @@ import 'package:google_fonts/google_fonts.dart'; import '../chats/chat_list_screen.dart'; import 'password_2fa_screen.dart'; import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; class CodeConfirmationScreen extends StatefulWidget { final String phoneNumber; final String token; const CodeConfirmationScreen({ - super.key, + super.key, required this.phoneNumber, required this.token, }); @@ -20,11 +21,17 @@ class CodeConfirmationScreen extends StatefulWidget { State createState() => _CodeConfirmationScreenState(); } -class _CodeConfirmationScreenState extends State { +class _CodeConfirmationScreenState extends State + with TickerProviderStateMixin { final TextEditingController _codeController = TextEditingController(); final FocusNode _focusNode = FocusNode(); int _timerSeconds = 30; Timer? _timer; + Timer? _errorTimer; + + String? _errorMessage; + late AnimationController _shakeController; + late Animation _shakeAnimation; @override void initState() { @@ -33,11 +40,26 @@ class _CodeConfirmationScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); + + _shakeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _shakeAnimation = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: -8.0), weight: 1), + TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 2), + TweenSequenceItem(tween: Tween(begin: 8.0, end: -8.0), weight: 2), + TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 2), + TweenSequenceItem(tween: Tween(begin: 8.0, end: -4.0), weight: 2), + TweenSequenceItem(tween: Tween(begin: -4.0, end: 0.0), weight: 1), + ]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear)); } @override void dispose() { _timer?.cancel(); + _errorTimer?.cancel(); + _shakeController.dispose(); _codeController.dispose(); _focusNode.dispose(); super.dispose(); @@ -57,11 +79,18 @@ class _CodeConfirmationScreenState extends State { }); } + void _showError(String message) { + _errorTimer?.cancel(); + _shakeController.forward(from: 0); + setState(() => _errorMessage = message); + _errorTimer = Timer(const Duration(seconds: 3), () { + if (mounted) setState(() => _errorMessage = null); + }); + } + void _resendCode() { if (_timerSeconds == 0) { _startTimer(); - // TODO: вызвать accountModule.resendCode - print('Resending code to ${widget.phoneNumber}'); } } @@ -78,29 +107,24 @@ class _CodeConfirmationScreenState extends State { if (result.requiresPassword) { final trackId = result.challengeTrackId; - + if (trackId == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Ошибка: отсутствуют данные для 2FA')), - ); + showCustomNotification(context, 'Ошибка: отсутствуют данные для 2FA'); return; } - + Navigator.pushReplacement( context, MaterialPageRoute( - builder: (context) => Password2FAScreen( - trackId: trackId, - hint: result.challengeHint, - ), + builder: (context) => + Password2FAScreen(trackId: trackId, hint: result.challengeHint), ), ); return; } - // Если 2FA не требуется, делаем login - final loginResult = await accountModule.login(); - + await accountModule.login(); + if (!mounted) return; Navigator.pushAndRemoveUntil( @@ -110,19 +134,15 @@ class _CodeConfirmationScreenState extends State { ); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Ошибка: $e')), - ); + _showError(e.toString()); } } - void _navigateToChats() { - _verifyCode(); - } - @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final hasError = _errorMessage != null; + return Scaffold( backgroundColor: cs.surface, appBar: AppBar( @@ -159,92 +179,155 @@ class _CodeConfirmationScreenState extends State { ), ), const SizedBox(height: 12), - Stack( - children: [ - Opacity( - opacity: 0, - child: SizedBox( - height: 0, - width: 0, - child: TextField( - controller: _codeController, - focusNode: _focusNode, - keyboardType: TextInputType.number, - autofillHints: const [AutofillHints.oneTimeCode], - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(6), - ], - onChanged: (value) { - setState(() {}); - if (value.length == 6) { - _navigateToChats(); - } - }, + AnimatedBuilder( + animation: _shakeAnimation, + builder: (context, child) => Transform.translate( + offset: Offset(_shakeAnimation.value, 0), + child: child, + ), + child: Stack( + children: [ + Opacity( + opacity: 0, + child: SizedBox( + height: 0, + width: 0, + child: TextField( + controller: _codeController, + focusNode: _focusNode, + keyboardType: TextInputType.number, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + onChanged: (value) { + if (hasError) setState(() => _errorMessage = null); + setState(() {}); + if (value.length == 6) _verifyCode(); + }, + ), ), ), - ), - GestureDetector( - onTap: () => _focusNode.requestFocus(), - child: FittedBox( - child: Row( - children: List.generate(6, (index) { - bool isFocused = _codeController.text.length == index && _focusNode.hasFocus; - bool hasValue = _codeController.text.length > index; - String char = hasValue ? _codeController.text[index] : ''; + GestureDetector( + onTap: () => _focusNode.requestFocus(), + child: FittedBox( + child: Row( + children: List.generate(6, (index) { + final isFocused = + _codeController.text.length == index && + _focusNode.hasFocus; + final hasValue = + _codeController.text.length > index; + final char = hasValue + ? _codeController.text[index] + : ''; - return Container( - width: 44, - height: 54, - margin: EdgeInsets.only(right: index == 5 ? 0 : 10), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isFocused - ? cs.primary - : (hasValue ? cs.outlineVariant : Colors.transparent), - width: 1.5, + Color borderColor; + if (hasError && hasValue) { + borderColor = cs.error; + } else if (isFocused) { + borderColor = cs.primary; + } else if (hasValue) { + borderColor = cs.outlineVariant; + } else { + borderColor = Colors.transparent; + } + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 44, + height: 54, + margin: EdgeInsets.only( + right: index == 5 ? 0 : 10, ), - ), - alignment: Alignment.center, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 100), - transitionBuilder: (Widget child, Animation animation) { - return ScaleTransition( - scale: animation, - child: FadeTransition(opacity: animation, child: child), - ); - }, - child: Text( - char, - key: ValueKey(char + index.toString()), - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, + decoration: BoxDecoration( + color: hasError && hasValue + ? cs.error.withValues(alpha: 0.1) + : cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: borderColor, + width: 1.5, ), ), - ), - ); - }), + alignment: Alignment.center, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + transitionBuilder: + ( + Widget child, + Animation animation, + ) { + return ScaleTransition( + scale: animation, + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }, + child: Text( + char, + key: ValueKey( + char + + index.toString() + + (hasError ? 'e' : ''), + ), + style: TextStyle( + color: hasError && hasValue + ? cs.error + : cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + }), + ), ), ), - ), - ], + ], + ), + ), + const SizedBox(height: 16), + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + alignment: Alignment.topLeft, + child: hasError + ? Padding( + padding: const EdgeInsets.only(bottom: 8), + child: AnimatedOpacity( + opacity: hasError ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: Text( + _errorMessage!, + style: TextStyle( + color: cs.error, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ), + ) + : const SizedBox.shrink(), ), - const SizedBox(height: 24), GestureDetector( onTap: _resendCode, - child: Text( - _timerSeconds > 0 - ? 'Отправить повторно через $_timerSeconds сек.' - : 'Отправить код по SMS', + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), style: TextStyle( - color: cs.tertiary, + color: _timerSeconds > 0 ? cs.outline : cs.tertiary, fontSize: 14, fontWeight: FontWeight.w400, ), + child: Text( + _timerSeconds > 0 + ? 'Отправить повторно через $_timerSeconds сек.' + : 'Отправить код по SMS', + ), ), ), const Spacer(), @@ -253,9 +336,7 @@ class _CodeConfirmationScreenState extends State { children: [ FloatingActionButton( onPressed: () { - if (_codeController.text.length == 6) { - _navigateToChats(); - } + if (_codeController.text.length == 6) _verifyCode(); }, backgroundColor: _codeController.text.length == 6 ? cs.primaryContainer @@ -266,7 +347,9 @@ class _CodeConfirmationScreenState extends State { ), child: Icon( Icons.arrow_forward, - color: _codeController.text.length == 6 ? cs.onPrimaryContainer : cs.onSurfaceVariant, + color: _codeController.text.length == 6 + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, ), ), ], diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 89b463b..a72e298 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -23,6 +24,8 @@ class _LoginScreenState extends State { late CountryName _selectedCountry; bool _isPhoneValid = false; bool _isTOSRead = false; + String? _phoneError; + Timer? _phoneErrorTimer; @override void initState() { @@ -31,6 +34,13 @@ class _LoginScreenState extends State { _checkTOS(); } + @override + void dispose() { + _phoneErrorTimer?.cancel(); + _phoneController.dispose(); + super.dispose(); + } + Future _checkTOS() async { final prefs = await SharedPreferences.getInstance(); if (mounted) { @@ -308,9 +318,19 @@ class _LoginScreenState extends State { ); } + void _showPhoneError(String message) { + _phoneErrorTimer?.cancel(); + setState(() => _phoneError = message); + _phoneErrorTimer = Timer(const Duration(seconds: 4), () { + if (mounted) setState(() => _phoneError = null); + }); + } + void _showPhoneConfirmationDialog(String formattedPhone) { + final screenContext = context; + showGeneralDialog( - context: context, + context: screenContext, barrierDismissible: true, barrierLabel: '', barrierColor: Colors.black54, @@ -374,18 +394,22 @@ class _LoginScreenState extends State { TextButton( onPressed: () async { Navigator.pop(context); - - final fullPhone = '${_selectedCountry.phoneCode}${_phoneController.text}'; - + + final fullPhone = + '${_selectedCountry.phoneCode}${_phoneController.text}'; + try { - final result = await accountModule.requestCode(fullPhone); - + final result = await accountModule.requestCode( + fullPhone, + ); + if (mounted) { Navigator.push( - context, + screenContext, MaterialPageRoute( builder: (context) => CodeConfirmationScreen( - phoneNumber: '${_selectedCountry.phoneCode} $formattedPhone', + phoneNumber: + '${_selectedCountry.phoneCode} $formattedPhone', token: result.token, ), ), @@ -393,7 +417,7 @@ class _LoginScreenState extends State { } } catch (e) { if (mounted) { - showCustomNotification(context, 'Ошибка: $e'); + _showPhoneError(e.toString()); } } }, @@ -719,7 +743,28 @@ class _LoginScreenState extends State { ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 8), + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + alignment: Alignment.topLeft, + child: _phoneError != null + ? Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + _phoneError!, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ) + : const SizedBox.shrink(), + ), + const SizedBox(height: 16), TextButton( onPressed: () => _showOtherLoginMethods(context), child: Text( diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index faf6406..9a99a42 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -2,16 +2,13 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import '../chats/chat_list_screen.dart'; import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; class Password2FAScreen extends StatefulWidget { final String trackId; final String? hint; - const Password2FAScreen({ - super.key, - required this.trackId, - this.hint, - }); + const Password2FAScreen({super.key, required this.trackId, this.hint}); @override State createState() => _Password2FAScreenState(); @@ -43,8 +40,7 @@ class _Password2FAScreenState extends State { if (!mounted) return; - // После успешной 2FA делаем login - final loginResult = await accountModule.login(); + await accountModule.login(); if (!mounted) return; @@ -60,9 +56,7 @@ class _Password2FAScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Неверный пароль: $e')), - ); + showCustomNotification(context, 'Неверный пароль: $e'); } } @@ -131,7 +125,9 @@ class _Password2FAScreenState extends State { ), suffixIcon: IconButton( icon: Icon( - _isPasswordVisible ? Icons.visibility_off : Icons.visibility, + _isPasswordVisible + ? Icons.visibility_off + : Icons.visibility, color: cs.onSurfaceVariant, ), onPressed: () { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 678ac56..451322a 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'dart:math'; @@ -9,6 +10,10 @@ import 'chat_screen.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; import '../profile/settings_tab.dart'; +import '../../../backend/api.dart'; +import '../../../backend/modules/chats.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../main.dart' show api; class ChatListScreen extends StatefulWidget { const ChatListScreen({super.key}); @@ -25,7 +30,12 @@ class _ChatListScreenState extends State late AnimationController _fabController; final Set _selectedChats = {}; final ScrollController _scrollController = ScrollController(); - double _pullRatio = 0.0; // 0.0 = folded (hidden row), 1.0 = fully expanded + double _pullRatio = 0.0; + ProfileData? _profile; + List _chats = []; + SessionState _sessionState = SessionState.disconnected; + StreamSubscription? _stateSub; + bool _shouldCollapseSearch = false; bool get _isSelectionMode => _selectedChats.isNotEmpty; @@ -36,12 +46,21 @@ class _ChatListScreenState extends State } else { _selectedChats.add(chatId); } + + if (_isSelectionMode) { + if (_scrollController.hasClients && _scrollController.offset < 132) { + _shouldCollapseSearch = true; + } + } else { + _shouldCollapseSearch = false; + } }); } void _clearSelection() { setState(() { _selectedChats.clear(); + _shouldCollapseSearch = false; }); } @@ -53,11 +72,45 @@ class _ChatListScreenState extends State duration: const Duration(milliseconds: 350), ); _scrollController.addListener(_onScroll); + + _sessionState = api.state; + _stateSub = api.stateStream.listen((state) { + if (mounted) setState(() => _sessionState = state); + }); + + _loadProfile(); + } + + Future _loadProfile() async { + final p = await AppDatabase.loadActiveProfile(); + if (p != null) { + final chats = await ChatsModule.getChats(p.id); + if (mounted) { + setState(() { + _profile = p; + _chats = chats; + }); + } + } + } + + String _formatTime(int? timestamp) { + if (timestamp == null || timestamp == 0) return ''; + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final h = dt.hour.toString().padLeft(2, '0'); + final m = dt.minute.toString().padLeft(2, '0'); + return '$h:$m'; } void _onScroll() { if (_scrollController.hasClients) { final double offset = _scrollController.offset; + if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) { + setState(() { + _shouldCollapseSearch = true; + }); + } + if (offset < 0) { final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); if (newRatio != _pullRatio) { @@ -75,6 +128,7 @@ class _ChatListScreenState extends State @override void dispose() { + _stateSub?.cancel(); _fabController.dispose(); _scrollController.dispose(); super.dispose(); @@ -133,9 +187,9 @@ class _ChatListScreenState extends State child: AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, - height: _isSelectionMode + height: _shouldCollapseSearch ? 0 - : (132 + (96 * _pullRatio)), + : (100 + (96 * _pullRatio)), color: Colors.transparent, clipBehavior: Clip.hardEdge, child: AnimatedContainer( @@ -143,7 +197,7 @@ class _ChatListScreenState extends State curve: Curves.easeOutCubic, transform: Matrix4.translationValues( 0, - _isSelectionMode ? -100 : 0, + _shouldCollapseSearch ? -100 : 0, 0, ), child: Column( @@ -153,7 +207,7 @@ class _ChatListScreenState extends State 20, 12, 20, - 4, + 2, ), child: Row( mainAxisAlignment: @@ -189,7 +243,9 @@ class _ChatListScreenState extends State ), ), Text( - 'Подключение...', + _sessionState == SessionState.online + ? (_profile?.firstName ?? 'Чат') + : 'Подключение...', style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -266,9 +322,9 @@ class _ChatListScreenState extends State Padding( padding: const EdgeInsets.fromLTRB( 20, - 4, + 2, 20, - 12, + 8, ), child: Container( height: 44, @@ -316,9 +372,7 @@ class _ChatListScreenState extends State ), ), SliverPadding( - padding: EdgeInsets.only( - top: _isSelectionMode ? 64 : 0, - ), + padding: EdgeInsets.zero, sliver: SliverToBoxAdapter( child: AnimatedContainer( duration: const Duration(milliseconds: 300), @@ -337,7 +391,7 @@ class _ChatListScreenState extends State scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: 20, - vertical: 8, + vertical: 4, ), physics: const BouncingScrollPhysics(), children: [ @@ -363,53 +417,24 @@ class _ChatListScreenState extends State ), ), SliverList( - delegate: SliverChildListDelegate([ - _buildChatItem( - 'stas', - 'Станислав', - 'Хорошо', - '10:07', - 'https://i.pravatar.cc/150?u=stas', - isOnline: true, - isRead: true, - ), - _buildChatItem( - 'ilya', - 'Илья', - 'печатает...', - '10:07', - 'https://i.pravatar.cc/150?u=ilya', - isOnline: true, - isTyping: true, - unreadCount: 1, - ), - _buildChatItem( - 'veronika', - 'Вероника', - 'Спасибо', - '09:56', - 'https://i.pravatar.cc/150?u=veronika', - isRead: true, - ), - _buildChatItem( - 'komet', - 'Komet Client', - 'Кстати. Смотрите, какую шту...', - '09:56', - 'https://i.pravatar.cc/150?u=komet', - unreadCount: 5, - isMuted: true, - ), - _buildChatItem( - 'podezd', - '4-й подъезд', - 'Людмила: Сколько?', - '09:34', - 'https://i.pravatar.cc/150?u=podezd', - unreadCount: 78, - isMuted: true, - ), - ]), + delegate: SliverChildBuilderDelegate((context, index) { + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, childCount: _chats.length), + ), + const SliverPadding( + padding: EdgeInsets.only(bottom: 120), ), ], ), // CustomScrollView @@ -532,7 +557,7 @@ class _ChatListScreenState extends State ), ), ), - if (!_isSelectionMode) ...[ + if (!_isSelectionMode && _currentNavIndex == 0) ...[ if (_fabController.value > 0) Positioned( right: 20, @@ -726,7 +751,22 @@ class _ChatListScreenState extends State ), leading: Stack( children: [ - CircleAvatar(radius: 24, backgroundImage: NetworkImage(imageUrl)), + CircleAvatar( + radius: 24, + backgroundColor: cs.surfaceContainerHighest, + backgroundImage: imageUrl.isNotEmpty + ? NetworkImage(imageUrl) + : null, + child: imageUrl.isEmpty + ? Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 20, + ), + ) + : null, + ), if (isSelected) Positioned( right: -2, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 50f586d..6d2715b 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -58,10 +58,20 @@ class _ChatScreenState extends State { titleSpacing: 0, title: Row( children: [ - CircleAvatar( - radius: 18, - backgroundImage: NetworkImage(widget.imageUrl), - ), + if (widget.imageUrl.isNotEmpty) + CircleAvatar( + radius: 18, + backgroundImage: NetworkImage(widget.imageUrl), + ) + else + CircleAvatar( + radius: 18, + backgroundColor: Colors.blueGrey, + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), const SizedBox(width: 12), Expanded( child: Column( @@ -111,11 +121,17 @@ class _ChatScreenState extends State { body: Stack( children: [ Positioned.fill( - child: Image.network( - 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809', - fit: BoxFit.cover, - opacity: const AlwaysStoppedAnimation(0.4), - ), + child: widget.imageUrl.isNotEmpty + ? Image.network( + widget.imageUrl, + fit: BoxFit.cover, + opacity: const AlwaysStoppedAnimation(0.4), + ) + : Image.network( + 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809', + fit: BoxFit.cover, + opacity: const AlwaysStoppedAnimation(0.4), + ), ), const Positioned.fill( child: DecoratedBox( diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 95e1f6a..05ca27c 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,12 +1,42 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; -class SettingsTab extends StatelessWidget { +class SettingsTab extends StatefulWidget { const SettingsTab({super.key}); + @override + State createState() => _SettingsTabState(); +} + +class _SettingsTabState extends State { + ProfileData? _profile; + bool _isPhoneVisible = false; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + Future _loadProfile() async { + final p = await AppDatabase.loadActiveProfile(); + if (mounted) setState(() => _profile = p); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + + if (_profile == null) { + return const Center(child: CircularProgressIndicator()); + } + + final String fullName = + '${_profile!.firstName}${_profile!.lastName != null ? ' ${_profile!.lastName}' : ''}'; + final String phone = '+${_profile!.phone}'; + return Scaffold( backgroundColor: cs.surface, body: SafeArea( @@ -14,7 +44,9 @@ class SettingsTab extends StatelessWidget { child: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ - SliverToBoxAdapter(child: _buildHeader(context, cs)), + SliverToBoxAdapter( + child: _buildHeader(context, cs, fullName, phone), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), @@ -25,7 +57,7 @@ class SettingsTab extends StatelessWidget { _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), _SettingsItem( icon: Symbols.language, - label: 'Войти в сферум', + label: 'Войти в Сферум', ), ], ), @@ -55,7 +87,12 @@ class SettingsTab extends StatelessWidget { ); } - Widget _buildHeader(BuildContext context, ColorScheme cs) { + Widget _buildHeader( + BuildContext context, + ColorScheme cs, + String name, + String phone, + ) { return Padding( padding: const EdgeInsets.fromLTRB(8, 12, 8, 20), child: Column( @@ -95,23 +132,19 @@ class SettingsTab extends StatelessWidget { ), ), child: ClipOval( - child: Image.network( - 'https://i.pravatar.cc/150?u=ilya', - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => CircleAvatar( - backgroundColor: cs.primaryContainer, - child: Icon( - Symbols.person, - color: cs.onPrimaryContainer, - size: 40, - ), - ), - ), + child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty + ? Image.network( + _profile!.baseUrl!, + fit: BoxFit.cover, + errorBuilder: (context, _, __) => + _buildPlaceholderAvatar(cs, name), + ) + : _buildPlaceholderAvatar(cs, name), ), ), const SizedBox(height: 14), Text( - 'Илья Беларуских', + name, style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -119,39 +152,70 @@ class SettingsTab extends StatelessWidget { fontFamily: 'Outfit', ), ), - const SizedBox(height: 3), - Text( - '@everrnyan', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: _PhoneSpoiler( + text: phone, + isVisible: _isPhoneVisible, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.5, + ), + ), + ), + ), + const SizedBox(width: 4), + Icon( + _isPhoneVisible ? Symbols.visibility : Symbols.visibility_off, + size: 14, + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + ), + ], ), ], ), ); } + Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { + return Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + ); + } + Widget _buildSection( BuildContext context, ColorScheme cs, { required List<_SettingsItem> items, }) { - return ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Container( - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - ), - child: Column( - children: List.generate(items.length, (index) { - final item = items[index]; - final isLast = index == items.length - 1; - return _buildSettingsRow(context, cs, item, isLast: isLast); - }), - ), + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: List.generate(items.length, (index) { + final item = items[index]; + final isLast = index == items.length - 1; + return _buildSettingsRow(context, cs, item, isLast: isLast); + }), ), ); } @@ -168,6 +232,9 @@ class SettingsTab extends StatelessWidget { color: Colors.transparent, child: InkWell( onTap: () {}, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : null, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( @@ -220,3 +287,93 @@ class _SettingsItem { const _SettingsItem({required this.icon, required this.label}); } + +class _PhoneSpoiler extends StatefulWidget { + final String text; + final bool isVisible; + final TextStyle style; + + const _PhoneSpoiler({ + required this.text, + required this.isVisible, + required this.style, + }); + + @override + State<_PhoneSpoiler> createState() => _PhoneSpoilerState(); +} + +class _PhoneSpoilerState extends State<_PhoneSpoiler> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: widget.isVisible + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: SizedBox( + child: CustomPaint( + size: const Size(110, 16), + painter: _SpoilerPainter(_controller, widget.style.color!), + ), + ), + secondChild: Text(widget.text, style: widget.style), + ); + } +} + +class _SpoilerPainter extends CustomPainter { + final Animation animation; + final Color color; + + _SpoilerPainter(this.animation, this.color) : super(repaint: animation); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color.withValues(alpha: 0.15) + ..style = PaintingStyle.fill; + + // Draw the background + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width, size.height), + const Radius.circular(4), + ), + paint, + ); + + // Draw "noisy" particles + final particlePaint = Paint()..style = PaintingStyle.fill; + + // Simple noise effect with dots using animation value for movement + for (int i = 0; i < 60; i++) { + double dx = (i * 17.5 + animation.value * 20) % size.width; + double dy = (i * 13.7 + animation.value * 15) % size.height; + double opacity = (0.2 + 0.3 * (i % 5) / 5.0).clamp(0.0, 1.0); + particlePaint.color = color.withValues(alpha: opacity); + canvas.drawCircle(Offset(dx, dy), 1.2, particlePaint); + } + } + + @override + bool shouldRepaint(_SpoilerPainter oldDelegate) => true; +} diff --git a/lib/main.dart b/lib/main.dart index a50de9b..967727c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,9 @@ import 'package:google_fonts/google_fonts.dart'; import 'backend/api.dart'; import 'backend/modules/account.dart'; import 'core/storage/app_database.dart'; +import 'core/storage/token_storage.dart'; import 'frontend/screens/auth/login_screen.dart'; +import 'frontend/screens/chats/chat_list_screen.dart'; final api = Api(); final accountModule = AccountModule(api); @@ -42,10 +44,12 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - final baseScheme = darkDynamic ?? ColorScheme.fromSeed( - seedColor: _fallbackSeed, - brightness: Brightness.dark, - ); + final baseScheme = + darkDynamic ?? + ColorScheme.fromSeed( + seedColor: _fallbackSeed, + brightness: Brightness.dark, + ); final darkScheme = _adjustScheme(baseScheme); @@ -55,13 +59,66 @@ class MyApp extends StatelessWidget { theme: ThemeData( useMaterial3: true, colorScheme: darkScheme, - textTheme: GoogleFonts.interTextTheme( - ThemeData.dark().textTheme, - ), + textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme), ), - home: const LoginScreen(), + home: const _StartupScreen(), ); }, ); } } + +class _StartupScreen extends StatefulWidget { + const _StartupScreen(); + + @override + State<_StartupScreen> createState() => _StartupScreenState(); +} + +class _StartupScreenState extends State<_StartupScreen> { + @override + void initState() { + super.initState(); + _tryAutoLogin(); + } + + Future _tryAutoLogin() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + _goToLogin(); + return; + } + + try { + await accountModule.login(accountId: accountId); + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const ChatListScreen()), + ); + } + } catch (_) { + _goToLogin(); + } + } + + void _goToLogin() { + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: Center( + child: CircularProgressIndicator(color: cs.primary, strokeWidth: 2), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 8948ca6..64ede3c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -300,18 +300,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -601,10 +601,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: From da5ec16b8ea9e13662592eba7e8afc5fd0a4b6c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Fri, 3 Apr 2026 20:32:22 +0700 Subject: [PATCH 022/323] =?UTF-8?q?=D0=BF=D1=8F=D1=82=D0=BA=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=86=D0=B5=D0=BB=D1=83=D0=B9=D1=82=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=85=D1=83=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 4 +- lib/backend/modules/messages.dart | 163 +++++++++++++++ lib/core/protocol/packet.dart | 111 +++++----- lib/core/storage/app_database.dart | 62 +++++- lib/core/transport/receiver.dart | 16 +- .../screens/chats/chat_list_screen.dart | 6 +- lib/frontend/screens/chats/chat_screen.dart | 197 +++++++++++++----- lib/main.dart | 2 + 8 files changed, 446 insertions(+), 115 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 59efde3..1eeb9dd 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -199,8 +199,8 @@ class Api { logger.i('Сессия: ${state.name}'); } - void _onDataReceived(Uint8List data) { - for (final packet in _receiver.feed(data)) { + Future _onDataReceived(Uint8List data) async { + await for (final packet in _receiver.feed(data)) { _dispatcher.dispatch(packet); } } diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e69de29..61d7614 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/storage/app_database.dart'; + +class CachedMessage { + final String id; + final int accountId; + final int chatId; + final int senderId; + final String? text; + final int time; + final String? status; + final Map? payload; + + const CachedMessage({ + required this.id, + required this.accountId, + required this.chatId, + required this.senderId, + this.text, + required this.time, + this.status, + this.payload, + }); + + factory CachedMessage.fromDbRow(Map row) { + Map? payload; + final payloadRaw = row['payload']; + if (payloadRaw is String && payloadRaw.isNotEmpty) { + try { + payload = jsonDecode(payloadRaw) as Map; + } catch (_) {} + } + + return CachedMessage( + id: row['id'] as String, + accountId: row['account_id'] as int, + chatId: row['chat_id'] as int, + senderId: row['sender_id'] as int, + text: row['text'] as String?, + time: row['time'] as int, + status: row['status'] as String?, + payload: payload, + ); + } + + Map toDbRow() => { + 'id': id, + 'account_id': accountId, + 'chat_id': chatId, + 'sender_id': senderId, + 'text': text, + 'time': time, + 'status': status, + 'payload': payload != null ? jsonEncode(payload) : null, + }; +} + +class MessagesModule { + final Api _api; + + MessagesModule(this._api); + + /// Загружает историю сообщений для указанного чата. + /// + /// [fromTime] — опционально, время от которого грузить (миллисекунды). + /// Если не указано, грузит самые свежие. + /// [count] — количество сообщений. + Future> fetchHistory( + int accountId, + int chatId, { + int? fromTime, + int count = 50, + }) async { + final payload = { + 'chatId': chatId, + 'from': + fromTime ?? + (DateTime.now().millisecondsSinceEpoch + + 86400000), // +1 день для запаса + 'forward': 0, + 'backward': count, + 'getMessages': true, + }; + + final response = await _api.sendRequest(Opcode.chatHistory, payload); + + if (!response.isOk) return []; + + final data = response.payload; + if (data is! Map) return []; + + final messagesData = data['messages']; + if (messagesData is! List) return []; + + final List results = []; + final List> rows = []; + + // Обрабатываем сообщения "кусочками", чтобы не фризить UI при маппинге большого количества данных + for (var i = 0; i < messagesData.length; i++) { + final m = messagesData[i]; + if (m is! Map) continue; + + final msg = _parseMessage(m.cast(), accountId, chatId); + if (msg != null) { + results.add(msg); + rows.add(msg.toDbRow()); + } + + // Каждые 20 сообщений даем UI-потоку "дохнуть" и отрисовать кадр + if (i > 0 && i % 20 == 0) { + await Future.delayed(Duration.zero); + } + } + + if (rows.isNotEmpty) { + AppDatabase.saveMessages(rows).ignore(); + } + + return results; + } + + /// Загружает сообщения из локальной базы данных. + Future> getLocalHistory( + int accountId, + int chatId, { + int limit = 50, + int offset = 0, + }) async { + final rows = await AppDatabase.loadMessages( + accountId, + chatId, + limit: limit, + offset: offset, + ); + return rows.map(CachedMessage.fromDbRow).toList(); + } + + CachedMessage? _parseMessage( + Map m, + int accountId, + int chatId, + ) { + final id = m['id']?.toString(); + if (id == null) return null; + + return CachedMessage( + id: id, + accountId: accountId, + chatId: chatId, + senderId: (m['sender'] as int?) ?? 0, + text: m['text'] as String?, + time: (m['time'] as int?) ?? 0, + status: m['status'] as String?, + payload: m + .cast< + String, + dynamic + >(), // Сохраняем весь пакет для гибкости (аттачи и т.д.) + ); + } +} diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 3455d03..18658da 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'dart:isolate'; import 'package:dart_lz4/dart_lz4.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; @@ -69,71 +70,75 @@ Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { } /// Распаковка пакета от сервера -Packet unpackPacket(Uint8List packet) { - // Для удобства расшифровки пакета переводим в ByteData - ByteData packetData = ByteData.view( - packet.buffer, - packet.offsetInBytes, - packet.lengthInBytes, - ); +Future unpackPacket(Uint8List packet) async { + return Isolate.run(() { + // Для удобства расшифровки пакета переводим в ByteData + ByteData packetData = ByteData.view( + packet.buffer, + packet.offsetInBytes, + packet.lengthInBytes, + ); - // Объяснение каждой переменной смотри в классе Packet + // API версия и cmd представляют из себя 8 битные числа + final apiVer = packetData.getUint8(0) & 0xFF; + final cmd = packetData.getUint8(1) & 0xFF; - // API версия и cmd представляют из себя 8 битные числа - final apiVer = packetData.getUint8(0) & 0xFF; - final cmd = packetData.getUint8(1) & 0xFF; + // Sequence и OPCode представляют из себя 16 битные числа + final seq = packetData.getUint16(2) & 0xFFFF; + final opcode = packetData.getUint16(4) & 0xFFFF; - // Sequence и OPCode представляют из себя 16 битные числа - final seq = packetData.getUint16(2) & 0xFFFF; - final opcode = packetData.getUint16(4) & 0xFFFF; + // После базовых переменных идет длина пакета, является 32 битным числом + final packedLen = packetData.getUint32(6); - // После базовых переменных идет длина пакета, является 32 битным числом - final packedLen = packetData.getUint32(6); + // Compression flag показывает, сжат ли payload + final compFlag = packedLen >> 24; - // Compression flag показывает, сжат ли payload - final compFlag = packedLen >> 24; - - // Длина payload'а - final payloadLength = packedLen & 0xFFFFFF; + // Длина payload'а + final payloadLength = packedLen & 0xFFFFFF; - // Байты payload'а, могут быть сжаты LZ4 - var payloadBytes = packet.buffer.asUint8List(10, payloadLength); + // Байты payload'а, могут быть сжаты LZ4 + var payloadBytes = packet.buffer.asUint8List(10, payloadLength); - dynamic payload; - - if (payloadBytes.isNotEmpty) { - if (compFlag != 0) { - try { - payloadBytes = lz4Decompress( - payloadBytes, - decompressedSize: _maxDecompressedSize, - ); - - } catch (_) { + dynamic payload; + + if (payloadBytes.isNotEmpty) { + if (compFlag != 0) { try { - payloadBytes = _lz4BlockDecompress(payloadBytes, _maxDecompressedSize); - } catch (e) { - logger.e("LZ4 decompression error: $e", error: e); + payloadBytes = lz4Decompress( + payloadBytes, + decompressedSize: _maxDecompressedSize, + ); + } catch (_) { + try { + payloadBytes = _lz4BlockDecompress( + payloadBytes, + _maxDecompressedSize, + ); + } catch (e) { + // В изоляте нельзя использовать логгер, который пишет в терминал через зависимости Flutter, + // но простой print или throw сработает + print("LZ4 decompression error: $e"); + } + } + } + + try { + payload = msgpack.deserialize(payloadBytes); + } catch (e) { + if (payloadBytes.isNotEmpty) { + print("MsgPack deserialization error: $e"); } } } - try { - payload = msgpack.deserialize(payloadBytes); - } catch (e) { - if (payloadBytes.isNotEmpty) { - logger.e("MsgPack deserialization error: $e", error: e); - } - } - } - - return Packet( - api: apiVer, - cmd: cmd, - seq: seq, - opcode: opcode, - payload: payload, - ); + return Packet( + api: apiVer, + cmd: cmd, + seq: seq, + opcode: opcode, + payload: payload, + ); + }); } /// LZ4 block декомпрессия (без frame-заголовка). diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 7f5af5f..97b80a9 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -120,7 +120,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 5, + version: 6, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -141,6 +141,9 @@ class AppDatabase { await db.execute('DROP TABLE IF EXISTS chats_cache'); await db.execute(_chatsCacheSchema); } + if (oldVersion < 6) { + await db.execute(_messagesSchema); + } }, ); } @@ -164,6 +167,7 @@ class AppDatabase { await db.execute(_syncStateSchema); await db.execute(_chatsCacheSchema); await db.execute(_contactsSchema); + await db.execute(_messagesSchema); } static const _contactsSchema = ''' @@ -211,6 +215,21 @@ class AppDatabase { ) '''; + static const _messagesSchema = ''' + CREATE TABLE messages ( + id TEXT NOT NULL, + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + chat_id INTEGER NOT NULL, + sender_id INTEGER NOT NULL, + text TEXT, + time INTEGER NOT NULL, + status TEXT, + payload TEXT, + PRIMARY KEY (id, account_id), + FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE + ) + '''; + static Future saveProfile(ProfileData profile) async { final db = await _instance; await db.insert( @@ -360,4 +379,45 @@ class AppDatabase { whereArgs: [accountId], ); } + + static Future saveMessages(List> rows) async { + final db = await _instance; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + 'messages', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + } + + static Future>> loadMessages( + int accountId, + int chatId, { + int? limit, + int? offset, + }) async { + final db = await _instance; + return db.query( + 'messages', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + orderBy: 'time DESC', + limit: limit, + offset: offset, + ); + } + + static Future clearMessages(int accountId, int chatId) async { + final db = await _instance; + await db.delete( + 'messages', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + } } diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index 25af729..a14476e 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -10,22 +10,22 @@ class PacketReceiver { static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта - /// Добавляет байты в буфер, возвращает все собранные пакеты. + /// Добавляет байты в буфер, возвращает поток собранных пакетов. /// Неполные данные остаются в буфере до следующего вызова. - List feed(Uint8List data) { + Stream feed(Uint8List data) async* { final newBuffer = Uint8List(_buffer.length + data.length); newBuffer.setAll(0, _buffer); newBuffer.setAll(_buffer.length, data); _buffer = newBuffer; if (_buffer.length > _maxBufferSize) { - logger.e('PacketReceiver: переполнение буфера (${_buffer.length} B), сброс'); + logger.e( + 'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс', + ); reset(); - return []; + return; } - final packets = []; - while (_buffer.length >= headerSize) { final bd = ByteData.view( _buffer.buffer, @@ -42,13 +42,11 @@ class PacketReceiver { _buffer = _buffer.sublist(totalLength); try { - packets.add(unpackPacket(packetBytes)); + yield await unpackPacket(packetBytes); } catch (e) { logger.e('PacketReceiver: ошибка распаковки: $e'); } } - - return packets; } void reset() { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 451322a..cda1a0b 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -735,7 +735,11 @@ class _ChatListScreenState extends State Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen(name: name, imageUrl: imageUrl), + builder: (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + ), ), ); } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 6d2715b..d5484b2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,31 +1,63 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'dart:ui'; +import '../../../main.dart'; +import '../../../core/storage/app_database.dart'; class ChatScreen extends StatefulWidget { + final int chatId; final String name; final String imageUrl; - const ChatScreen({super.key, required this.name, required this.imageUrl}); + const ChatScreen({ + super.key, + required this.chatId, + required this.name, + required this.imageUrl, + }); @override State createState() => _ChatScreenState(); } -class _ChatScreenState extends State { +class _ChatScreenState extends State + with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); bool _hasText = false; + bool _isLoading = true; + late AnimationController _shimmerController; @override void initState() { super.initState(); _messageController.addListener(_onTextChanged); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadHistory(); + } + + Future _loadHistory() async { + try { + final activeProfile = await AppDatabase.loadActiveProfile(); + final myId = activeProfile?.id ?? 0; + await messagesModule.fetchHistory(myId, widget.chatId); + } catch (e) { + debugPrint('Background history fetch failed: $e'); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } } @override void dispose() { _messageController.removeListener(_onTextChanged); _messageController.dispose(); + _shimmerController.dispose(); super.dispose(); } @@ -42,9 +74,8 @@ class _ChatScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, - extendBodyBehindAppBar: true, appBar: AppBar( - backgroundColor: const Color(0xFF1B1B1B).withOpacity(0.8), + backgroundColor: const Color(0xFF1B1B1B), elevation: 0, surfaceTintColor: Colors.transparent, leading: IconButton( @@ -77,21 +108,17 @@ class _ChatScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text( - widget.name, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], + Text( + widget.name, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), ), const Text( - 'Connecting...', + 'last seen recently', style: TextStyle( color: Colors.grey, fontSize: 12, @@ -118,38 +145,112 @@ class _ChatScreenState extends State { ), ], ), - body: Stack( + body: Column( children: [ - Positioned.fill( - child: widget.imageUrl.isNotEmpty - ? Image.network( - widget.imageUrl, - fit: BoxFit.cover, - opacity: const AlwaysStoppedAnimation(0.4), - ) - : Image.network( - 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809', - fit: BoxFit.cover, - opacity: const AlwaysStoppedAnimation(0.4), - ), + Expanded( + child: _isLoading + ? _buildShimmerLoading() + : const SizedBox.shrink(), ), - const Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black87], - ), - ), - ), - ), - Column(children: [const Spacer(), _buildInputArea(context)]), + _buildInputArea(context), ], ), ); } + Widget _buildShimmerLoading() { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + (0.4 * _shimmerController.value); + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 8, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final hasImage = index % 3 == 0; + final hasReactions = index % 2 == 0; + final width1 = 60.0 + (index * 15 % 50); + final width2 = 120.0 + (index * 25 % 80); + + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Color(0xFF2B2B2B), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: width1, + height: 10, + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox(height: 6), + Container( + width: width2, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(10), + ), + ), + if (hasImage) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + height: 120, + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(12), + ), + ), + ], + if (hasReactions) ...[ + const SizedBox(height: 8), + Row( + children: List.generate( + 3, + (i) => Container( + width: 32, + height: 16, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + Widget _buildInputArea(BuildContext context) { return SafeArea( child: Padding( @@ -160,7 +261,6 @@ class _ChatScreenState extends State { Expanded( child: AnimatedContainer( duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, constraints: const BoxConstraints( minHeight: 54, maxHeight: 180, @@ -173,10 +273,7 @@ class _ChatScreenState extends State { width: 0.5, ), ), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 0, - ), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -205,7 +302,9 @@ class _ChatScreenState extends State { ), border: InputBorder.none, isDense: true, - contentPadding: EdgeInsets.zero, + contentPadding: EdgeInsets.symmetric( + vertical: 14, + ), // Выравниваем по Y ), ), ), diff --git a/lib/main.dart b/lib/main.dart index 967727c..a4dc755 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'backend/api.dart'; import 'backend/modules/account.dart'; +import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; import 'frontend/screens/auth/login_screen.dart'; @@ -10,6 +11,7 @@ import 'frontend/screens/chats/chat_list_screen.dart'; final api = Api(); final accountModule = AccountModule(api); +final messagesModule = MessagesModule(api); void main() async { WidgetsFlutterBinding.ensureInitialized(); From 676c2c715f7729bbd97f7cd1eb9ba008c6a911d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Fri, 3 Apr 2026 20:54:12 +0700 Subject: [PATCH 023/323] =?UTF-8?q?=D0=B1=D0=BB=D1=8F=20=D1=8D=D1=82=D0=BE?= =?UTF-8?q?=20=D1=81=D1=8D=D0=BA=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 2 - lib/core/protocol/packet.dart | 2 - .../screens/chats/chat_list_screen.dart | 139 +++++++++++++++++- lib/frontend/screens/chats/chat_screen.dart | 44 +++++- lib/main.dart | 19 ++- 5 files changed, 182 insertions(+), 24 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 61d7614..2cc90a7 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -97,7 +97,6 @@ class MessagesModule { final List results = []; final List> rows = []; - // Обрабатываем сообщения "кусочками", чтобы не фризить UI при маппинге большого количества данных for (var i = 0; i < messagesData.length; i++) { final m = messagesData[i]; if (m is! Map) continue; @@ -108,7 +107,6 @@ class MessagesModule { rows.add(msg.toDbRow()); } - // Каждые 20 сообщений даем UI-потоку "дохнуть" и отрисовать кадр if (i > 0 && i % 20 == 0) { await Future.delayed(Duration.zero); } diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 18658da..2cb5768 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -3,8 +3,6 @@ import 'dart:isolate'; import 'package:dart_lz4/dart_lz4.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; -import '../utils/logger.dart'; - /// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10 const int headerSize = 10; const int _maxDecompressedSize = 1048576; // 1 MB diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index cda1a0b..fd1b757 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -23,10 +23,11 @@ class ChatListScreen extends StatefulWidget { } class _ChatListScreenState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { String _selectedCategory = 'Все чаты'; int _currentNavIndex = 0; bool _isFabOpen = false; + bool _showCacheWarning = false; late AnimationController _fabController; final Set _selectedChats = {}; final ScrollController _scrollController = ScrollController(); @@ -64,6 +65,9 @@ class _ChatListScreenState extends State }); } + bool _isInitialLoading = true; + late AnimationController _shimmerController; + @override void initState() { super.initState(); @@ -71,11 +75,26 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _scrollController.addListener(_onScroll); _sessionState = api.state; _stateSub = api.stateStream.listen((state) { - if (mounted) setState(() => _sessionState = state); + if (mounted) { + setState(() { + _sessionState = state; + if (state == SessionState.disconnected && _chats.isNotEmpty) { + _showCacheWarning = true; + } + if (state == SessionState.online) { + _showCacheWarning = false; + } + }); + } }); _loadProfile(); @@ -89,6 +108,13 @@ class _ChatListScreenState extends State setState(() { _profile = p; _chats = chats; + _isInitialLoading = false; + }); + } + } else { + if (mounted) { + setState(() { + _isInitialLoading = false; }); } } @@ -102,6 +128,59 @@ class _ChatListScreenState extends State return '$h:$m'; } + Widget _buildChatShimmer() { + final cs = Theme.of(context).colorScheme; + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 120, + height: 14, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(7), + ), + ), + const SizedBox(height: 10), + Container( + width: double.infinity, + height: 10, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(5), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + void _onScroll() { if (_scrollController.hasClients) { final double offset = _scrollController.offset; @@ -319,6 +398,49 @@ class _ChatListScreenState extends State ), ), ), + if (_showCacheWarning) + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 0, + 20, + 8, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: cs.errorContainer.withOpacity( + 0.3, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.error.withOpacity(0.2), + ), + ), + child: Row( + children: [ + Icon( + Symbols.cloud_off, + size: 18, + color: cs.error, + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Ошибка соединения, сейчас вы смотрите КЕШ', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), Padding( padding: const EdgeInsets.fromLTRB( 20, @@ -418,6 +540,9 @@ class _ChatListScreenState extends State ), SliverList( delegate: SliverChildBuilderDelegate((context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } final chat = _chats[index]; return _buildChatItem( chat.id.toString(), @@ -431,10 +556,10 @@ class _ChatListScreenState extends State unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, ); - }, childCount: _chats.length), + }, childCount: _isInitialLoading ? 10 : _chats.length), ), const SliverPadding( - padding: EdgeInsets.only(bottom: 120), + padding: EdgeInsets.only(bottom: 100), ), ], ), // CustomScrollView @@ -449,7 +574,7 @@ class _ChatListScreenState extends State curve: Curves.easeOutCubic, left: 8, right: 8, - bottom: _isSelectionMode ? -100 : 24.0, + bottom: _isSelectionMode ? -100 : 10.0, child: RepaintBoundary( child: Container( height: 68, @@ -561,7 +686,7 @@ class _ChatListScreenState extends State if (_fabController.value > 0) Positioned( right: 20, - bottom: 110 + 74, + bottom: 90 + 74, child: RepaintBoundary( child: Transform.scale( scale: val, @@ -575,7 +700,7 @@ class _ChatListScreenState extends State ), Positioned( right: 20, - bottom: 110, + bottom: 90, child: FloatingActionButton( onPressed: _toggleFab, backgroundColor: cs.primaryContainer, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index d5484b2..ab8c985 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,6 +1,9 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; +import '../../../backend/api.dart'; +import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; class ChatScreen extends StatefulWidget { @@ -25,6 +28,7 @@ class _ChatScreenState extends State bool _hasText = false; bool _isLoading = true; late AnimationController _shimmerController; + List _messages = []; @override void initState() { @@ -34,17 +38,45 @@ class _ChatScreenState extends State vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(); + _loadHistory(); } Future _loadHistory() async { + final activeProfile = await AppDatabase.loadActiveProfile(); + final myId = activeProfile?.id ?? 0; + + final cachedRows = await AppDatabase.loadMessages( + myId, + widget.chatId, + limit: 100, + ); + if (mounted && cachedRows.isNotEmpty) { + setState(() { + _messages = cachedRows.map((r) => CachedMessage.fromDbRow(r)).toList(); + if (api.state == SessionState.online) { + _isLoading = false; + } + }); + } + try { - final activeProfile = await AppDatabase.loadActiveProfile(); - final myId = activeProfile?.id ?? 0; await messagesModule.fetchHistory(myId, widget.chatId); + final updatedRows = await AppDatabase.loadMessages( + myId, + widget.chatId, + limit: 100, + ); + if (mounted) { + setState(() { + _messages = updatedRows + .map((r) => CachedMessage.fromDbRow(r)) + .toList(); + _isLoading = false; + }); + } } catch (e) { - debugPrint('Background history fetch failed: $e'); - } finally { + debugPrint('Error fetching history: $e'); if (mounted) { setState(() { _isLoading = false; @@ -148,7 +180,9 @@ class _ChatScreenState extends State body: Column( children: [ Expanded( - child: _isLoading + child: + _isLoading || + (_messages.isEmpty && api.state != SessionState.online) ? _buildShimmerLoading() : const SizedBox.shrink(), ), diff --git a/lib/main.dart b/lib/main.dart index a4dc755..c8fb446 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,16 +91,19 @@ class _StartupScreenState extends State<_StartupScreen> { return; } + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const ChatListScreen()), + ); + } + try { await accountModule.login(accountId: accountId); - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => const ChatListScreen()), - ); - } - } catch (_) { - _goToLogin(); + } catch (e) { + debugPrint( + 'Background auto-login failed (safe to ignore if offline): $e', + ); } } From 543cd8cbce2e35598f7aae7dbdbdc1bd52f80819 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 19:53:06 +0300 Subject: [PATCH 024/323] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=89=D0=B8=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BD=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B8=D0=B7=20=D1=81=D0=BC=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 12 +-- lib/core/protocol/packet.dart | 20 ++++ lib/core/transport/dispatcher.dart | 4 +- .../auth/code_confirmation_screen.dart | 5 +- macos/Podfile.lock | 55 +++++++++++ macos/Runner.xcodeproj/project.pbxproj | 98 ++++++++++++++++++- .../contents.xcworkspacedata | 3 + 7 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 macos/Podfile.lock diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index b8ff3c3..a9af71a 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -7,13 +7,6 @@ import '../../core/utils/logger.dart'; import 'chats.dart'; import 'contacts.dart'; -class ServerException implements Exception { - final String message; - const ServerException(this.message); - @override - String toString() => message; -} - enum AuthRequestType { startAuth('START_AUTH'), resend('RESEND'), @@ -447,10 +440,7 @@ class AccountModule { void _checkPacketError(Packet packet, String method) { if (packet.isError) { - final errMsg = packet.payload is Map - ? (packet.payload as Map)['message'] ?? packet.payload.toString() - : packet.payload?.toString() ?? 'Неизвестная ошибка'; - throw ServerException(errMsg.toString()); + throw PacketError(messageFromErrorPayload(packet.payload)); } } } diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 2cb5768..6970165 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -52,6 +52,26 @@ class Packet { 'Packet(ver=$api cmd=$cmd seq=$seq opcode=$opcode payload=$payload)'; } +class PacketError implements Exception { + final String message; + const PacketError(this.message); + @override + String toString() => message; +} + +String messageFromErrorPayload(dynamic payload) { + if (payload is Map) { + for (final key in ['localizedMessage', 'message', 'title']) { + final v = payload[key]; + if (v is String && v.trim().isNotEmpty) return v.trim(); + } + return 'Неизвестная ошибка'; + } + if (payload == null) return 'Неизвестная ошибка'; + final s = payload.toString(); + return s.isNotEmpty ? s : 'Неизвестная ошибка'; +} + /// Упаковка пакета для отправки на сервер Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { final header = ByteData(headerSize); diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index 103f033..bb22136 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -68,7 +68,9 @@ class PacketDispatcher { } if (packet.isError) { - completer.completeError(packet); + completer.completeError( + PacketError(messageFromErrorPayload(packet.payload)), + ); } else { completer.complete(packet); } diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index f86f656..55339be 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -306,8 +306,9 @@ class _CodeConfirmationScreenState extends State _errorMessage!, style: TextStyle( color: cs.error, - fontSize: 13, - fontWeight: FontWeight.w400, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.35, ), ), ), diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..14765ba --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,55 @@ +PODS: + - device_info_plus (0.0.1): + - FlutterMacOS + - dynamic_color (0.0.2): + - FlutterMacOS + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + - flutter_timezone (0.1.0): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - dynamic_color (from `Flutter/ephemeral/.symlinks/plugins/dynamic_color/macos`) + - flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + +EXTERNAL SOURCES: + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + dynamic_color: + :path: Flutter/ephemeral/.symlinks/plugins/dynamic_color/macos + flutter_secure_storage_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin + flutter_timezone: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos + FlutterMacOS: + :path: Flutter/ephemeral + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + +SPEC CHECKSUMS: + device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 + dynamic_color: 5fdff3953fb3457311091863f72914fc76ea3209 + flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d + flutter_timezone: b3bc0c587d8780d395651284a1ff46eb1e5753ac + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 2110d8f..7188c51 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -21,12 +21,14 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 1B0AAF467EC25EF9159DC6E7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D452F147ED5EA5A4D3B2F69C /* Pods_Runner.framework */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 5891C53C95FBB6606F87B07B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC834A0EF5C5ACF230BCDB0F /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 1EEBCAF84AEC15439093B432 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 2F1D1037B18DFACF3DCB068D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* komet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "komet.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* komet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = komet.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -77,7 +81,13 @@ 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9429F298C2BDD78E966599A3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A30CA7B4319C9569917A735D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + BC834A0EF5C5ACF230BCDB0F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CC0F01C1D03006A1DD67A151 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + D452F147ED5EA5A4D3B2F69C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D9473A96CB79E1B69ACF81CC /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5891C53C95FBB6606F87B07B /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,12 +103,27 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1B0AAF467EC25EF9159DC6E7 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1DB0FC36071C177710C75E9D /* Pods */ = { + isa = PBXGroup; + children = ( + D9473A96CB79E1B69ACF81CC /* Pods-Runner.debug.xcconfig */, + A30CA7B4319C9569917A735D /* Pods-Runner.release.xcconfig */, + 2F1D1037B18DFACF3DCB068D /* Pods-Runner.profile.xcconfig */, + 9429F298C2BDD78E966599A3 /* Pods-RunnerTests.debug.xcconfig */, + 1EEBCAF84AEC15439093B432 /* Pods-RunnerTests.release.xcconfig */, + CC0F01C1D03006A1DD67A151 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 331C80D6294CF71000263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -125,6 +151,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 1DB0FC36071C177710C75E9D /* Pods */, ); sourceTree = ""; }; @@ -175,6 +202,8 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + D452F147ED5EA5A4D3B2F69C /* Pods_Runner.framework */, + BC834A0EF5C5ACF230BCDB0F /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 5D8614419BBD7BD81C481542 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + C78DF3AD05984D1177E03977 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + AAC6FCD26EB13D7261C78A3C /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -329,6 +361,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 5D8614419BBD7BD81C481542 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + AAC6FCD26EB13D7261C78A3C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C78DF3AD05984D1177E03977 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9429F298C2BDD78E966599A3 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1EEBCAF84AEC15439093B432 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = CC0F01C1D03006A1DD67A151 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + From b7624c48ca3687db1fe726357b9bc51af40d8ec4 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 20:28:10 +0300 Subject: [PATCH 025/323] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B5=D0=B9=20=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B9=20=D0=B2=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 803 +++++++++++------- 1 file changed, 479 insertions(+), 324 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index fd1b757..d25bf4e 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -32,6 +32,13 @@ class _ChatListScreenState extends State final Set _selectedChats = {}; final ScrollController _scrollController = ScrollController(); double _pullRatio = 0.0; + static const double _kStoriesPullTriggerPx = 16.0; + late AnimationController _storiesRevealController; + double _revealAnimBegin = 0.0; + double _closeAnimBegin = 0.0; + bool _storiesAnimClosing = false; + bool _storiesDockedOpen = false; + double _storiesCloseIntentAccum = 0.0; ProfileData? _profile; List _chats = []; SessionState _sessionState = SessionState.disconnected; @@ -80,6 +87,14 @@ class _ChatListScreenState extends State duration: const Duration(milliseconds: 1500), )..repeat(); + _storiesRevealController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ) + ..addListener(_onStoriesRevealTick) + ..addStatusListener(_onStoriesRevealStatus); + _scrollController.addListener(_onScroll); _sessionState = api.state; @@ -181,6 +196,110 @@ class _ChatListScreenState extends State ); } + void _onStoriesRevealTick() { + if (!mounted) return; + final t = Curves.easeOutCubic.transform(_storiesRevealController.value); + setState(() { + if (_storiesAnimClosing) { + _pullRatio = _closeAnimBegin * (1.0 - t); + } else { + _pullRatio = _revealAnimBegin + (1.0 - _revealAnimBegin) * t; + } + }); + } + + void _onStoriesRevealStatus(AnimationStatus status) { + if (!mounted) return; + if (status == AnimationStatus.completed) { + setState(() { + if (_storiesAnimClosing) { + _pullRatio = 0.0; + _storiesDockedOpen = false; + _storiesAnimClosing = false; + } else { + _pullRatio = 1.0; + _storiesDockedOpen = true; + } + }); + } + } + + void _startStoriesAutoReveal(double suggestedFrom) { + if (_storiesRevealController.isAnimating && !_storiesAnimClosing) return; + if (_storiesDockedOpen) return; + _storiesRevealController.stop(); + _storiesAnimClosing = false; + final from = max(_pullRatio, suggestedFrom.clamp(0.0, 1.0)); + if (from >= 1.0) { + setState(() { + _pullRatio = 1.0; + _storiesDockedOpen = true; + }); + return; + } + _revealAnimBegin = from; + _storiesRevealController.duration = Duration( + milliseconds: (260 + 240 * (1.0 - from)).round(), + ); + _storiesRevealController.reset(); + _storiesRevealController.forward(from: 0); + } + + void _startStoriesAutoClose() { + if (_pullRatio <= 0 && + !_storiesDockedOpen && + !_storiesRevealController.isAnimating) { + return; + } + if (_storiesAnimClosing && _storiesRevealController.isAnimating) return; + _storiesCloseIntentAccum = 0.0; + _storiesRevealController.stop(); + _storiesAnimClosing = true; + final from = _pullRatio.clamp(0.0, 1.0); + if (from <= 0) { + setState(() { + _pullRatio = 0.0; + _storiesDockedOpen = false; + _storiesAnimClosing = false; + }); + return; + } + _closeAnimBegin = from; + _storiesRevealController.duration = Duration( + milliseconds: (260 + 240 * from).round(), + ); + _storiesRevealController.reset(); + _storiesRevealController.forward(from: 0); + } + + bool _onStoriesScrollNotification(ScrollNotification n) { + if (_currentNavIndex != 0) return false; + if (n is! ScrollUpdateNotification) return false; + if (!_scrollController.hasClients) return false; + if (!_storiesDockedOpen || _storiesRevealController.isAnimating) { + _storiesCloseIntentAccum = 0.0; + return false; + } + final m = n.metrics; + if (m.axis != Axis.vertical) return false; + if (m.pixels > m.minScrollExtent + 1.0) { + _storiesCloseIntentAccum = 0.0; + return false; + } + final d = n.scrollDelta; + if (d == null) return false; + if (d > 0) { + _storiesCloseIntentAccum = 0.0; + return false; + } + _storiesCloseIntentAccum += -d; + if (_storiesCloseIntentAccum >= _kStoriesPullTriggerPx) { + _storiesCloseIntentAccum = 0.0; + _startStoriesAutoClose(); + } + return false; + } + void _onScroll() { if (_scrollController.hasClients) { final double offset = _scrollController.offset; @@ -191,16 +310,28 @@ class _ChatListScreenState extends State } if (offset < 0) { - final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); - if (newRatio != _pullRatio) { + final dragRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); + if (_storiesRevealController.isAnimating) { + return; + } + if (!_storiesDockedOpen && offset.abs() >= _kStoriesPullTriggerPx) { + _startStoriesAutoReveal(dragRatio); + } else if (!_storiesDockedOpen) { + if (dragRatio != _pullRatio) { + setState(() { + _pullRatio = dragRatio; + }); + } + } + } else { + if (_storiesDockedOpen || _storiesRevealController.isAnimating) { + return; + } + if (_pullRatio > 0) { setState(() { - _pullRatio = newRatio; + _pullRatio = 0.0; }); } - } else if (_pullRatio > 0) { - setState(() { - _pullRatio = 0.0; - }); } } } @@ -209,6 +340,10 @@ class _ChatListScreenState extends State void dispose() { _stateSub?.cancel(); _fabController.dispose(); + _storiesRevealController + ..removeListener(_onStoriesRevealTick) + ..removeStatusListener(_onStoriesRevealStatus) + ..dispose(); _scrollController.dispose(); super.dispose(); } @@ -242,328 +377,340 @@ class _ChatListScreenState extends State if (_scrollController.hasClients && _scrollController.offset <= 0) { if (pointerSignal.scrollDelta.dy < 0) { - // Scrolled UP (pulling down) - setState(() { - _pullRatio = (_pullRatio + 0.2).clamp(0.0, 1.0); - }); + _startStoriesAutoReveal(max(_pullRatio, 0.18)); } else if (pointerSignal.scrollDelta.dy > 0 && _pullRatio > 0) { - // Scrolled DOWN (folding up) - setState(() { - _pullRatio = (_pullRatio - 0.2).clamp(0.0, 1.0); - }); + _startStoriesAutoClose(); } } } }, - child: CustomScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverToBoxAdapter( - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - height: _shouldCollapseSearch - ? 0 - : (100 + (96 * _pullRatio)), - color: Colors.transparent, - clipBehavior: Clip.hardEdge, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - transform: Matrix4.translationValues( - 0, - _shouldCollapseSearch ? -100 : 0, - 0, - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 12, - 20, - 2, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - if (_pullRatio < 0.8) - Opacity( - opacity: 1.0 - _pullRatio, - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only( - right: 8, - ), - child: Stack( - children: [ - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=dasha', - 0, - ), - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=mastika', - 1, - ), - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=stas', - 2, - ), - ], - ), - ), - ), - Text( - _sessionState == SessionState.online - ? (_profile?.firstName ?? 'Чат') - : 'Подключение...', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], - ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 16, - ), - ), - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Кнопка 1', - Symbols.settings, - ), - _buildPopupMenuItem( + child: NotificationListener( + onNotification: _onStoriesScrollNotification, + child: CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverToBoxAdapter( + child: ClipRect( + clipBehavior: Clip.hardEdge, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _shouldCollapseSearch + ? const SizedBox( + width: double.infinity, + height: 0, + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 12, + 20, 2, - 'Кнопка 2', - Symbols.notifications, ), - _buildPopupMenuItem( - 3, - 'Кнопка 3', - Symbols.shield, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (_pullRatio < 0.8) + Opacity( + opacity: 1.0 - _pullRatio, + child: Container( + width: + 50 * + (1.0 - _pullRatio), + height: 32, + margin: + const EdgeInsets.only( + right: 8, + ), + child: Stack( + children: [ + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=dasha', + 0, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=mastika', + 1, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=stas', + 2, + ), + ], + ), + ), + ), + Text( + _sessionState == + SessionState.online + ? (_profile + ?.firstName ?? + 'Чат') + : 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: + FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, + ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(16), + ), + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Кнопка 1', + Symbols.settings, + ), + _buildPopupMenuItem( + 2, + 'Кнопка 2', + Symbols.notifications, + ), + _buildPopupMenuItem( + 3, + 'Кнопка 3', + Symbols.shield, + ), + _buildPopupMenuItem( + 4, + 'Кнопка 4', + Symbols.info, + ), + ], + ), + ], ), - _buildPopupMenuItem( - 4, - 'Кнопка 4', - Symbols.info, + ), + SizedBox( + height: 96 * _pullRatio, + child: Opacity( + opacity: _pullRatio, + child: ListView( + scrollDirection: Axis.horizontal, + padding: + const EdgeInsets.symmetric( + horizontal: 20, + ), + children: [ + _buildStoryItem( + 'Даша', + 'https://i.pravatar.cc/150?u=dasha', + true, + ), + _buildStoryItem( + 'Мастика', + 'https://i.pravatar.cc/150?u=mastika', + false, + ), + ], + ), ), - ], - ), - ], - ), - ), - SizedBox( - height: 96 * _pullRatio, - child: Opacity( - opacity: _pullRatio, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], - ), - ), - ), - if (_showCacheWarning) - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 0, - 20, - 8, - ), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - color: cs.errorContainer.withOpacity( - 0.3, - ), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: cs.error.withOpacity(0.2), - ), - ), - child: Row( - children: [ - Icon( - Symbols.cloud_off, - size: 18, - color: cs.error, - ), - const SizedBox(width: 12), - const Expanded( - child: Text( - 'Ошибка соединения, сейчас вы смотрите КЕШ', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, + if (_showCacheWarning) + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 0, + 20, + 8, + ), + child: Container( + padding: + const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: cs.errorContainer + .withOpacity(0.3), + borderRadius: + BorderRadius.circular(12), + border: Border.all( + color: cs.error.withOpacity( + 0.2, + ), + ), + ), + child: Row( + children: [ + Icon( + Symbols.cloud_off, + size: 18, + color: cs.error, + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Ошибка соединения, сейчас вы смотрите КЕШ', + style: TextStyle( + fontSize: 12, + fontWeight: + FontWeight.w500, + ), + ), + ), + ], ), ), ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 2, - 20, - 8, - ), - child: Container( - height: 44, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - style: TextStyle( - color: cs.onSurface, - fontSize: 15, + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 2, + 20, + 8, + ), + child: Container( + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: + BorderRadius.circular(50), ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, - ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: + EdgeInsets.zero, + ), + ), + ), + ], ), ), ), ], ), - ), - ), - ], ), ), ), - ), - SliverPadding( - padding: EdgeInsets.zero, - sliver: SliverToBoxAdapter( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - height: 48, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - ui.PointerDeviceKind.touch, - ui.PointerDeviceKind.mouse, - ui.PointerDeviceKind.trackpad, - }, + SliverPadding( + padding: EdgeInsets.zero, + sliver: SliverToBoxAdapter( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + height: 48, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith( + dragDevices: { + ui.PointerDeviceKind.touch, + ui.PointerDeviceKind.mouse, + ui.PointerDeviceKind.trackpad, + }, + ), + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, ), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, + physics: const BouncingScrollPhysics(), + children: [ + _buildFolderChip('Все чаты'), + const SizedBox(width: 8), + _buildFolderChip('Контакты'), + const SizedBox(width: 8), + _buildFolderChip('Пидоры'), + const SizedBox(width: 8), + _buildFolderChip('Каналы'), + const SizedBox(width: 8), + _buildFolderChip('Группы'), + const SizedBox(width: 8), + _buildFolderChip('Боты'), + const SizedBox(width: 8), + _buildFolderChip('Избранное'), + const SizedBox(width: 8), + _buildFolderChip('Архив'), + ], ), - physics: const BouncingScrollPhysics(), - children: [ - _buildFolderChip('Все чаты'), - const SizedBox(width: 8), - _buildFolderChip('Контакты'), - const SizedBox(width: 8), - _buildFolderChip('Пидоры'), - const SizedBox(width: 8), - _buildFolderChip('Каналы'), - const SizedBox(width: 8), - _buildFolderChip('Группы'), - const SizedBox(width: 8), - _buildFolderChip('Боты'), - const SizedBox(width: 8), - _buildFolderChip('Избранное'), - const SizedBox(width: 8), - _buildFolderChip('Архив'), - ], ), ), ), ), - ), - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, childCount: _isInitialLoading ? 10 : _chats.length), - ), - const SliverPadding( - padding: EdgeInsets.only(bottom: 100), - ), - ], - ), // CustomScrollView - ), // Listener + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && + chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, + childCount: _isInitialLoading ? 10 : _chats.length, + ), + ), + const SliverPadding( + padding: EdgeInsets.only(bottom: 100), + ), + ], + ), + ), + ), const CallsTab(), const ContactsTab(), const SettingsTab(), @@ -785,31 +932,39 @@ class _ChatListScreenState extends State final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(right: 16), - child: Column( - children: [ - Container( - padding: const EdgeInsets.all(2.5), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: hasUpdate - ? Border.all(color: cs.primary, width: 2) - : Border.all(color: cs.outlineVariant), - ), - child: CircleAvatar( - radius: 26, - backgroundImage: NetworkImage(imageUrl), - ), + child: SizedBox( + width: 68, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(2.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: hasUpdate + ? Border.all(color: cs.primary, width: 2) + : Border.all(color: cs.outlineVariant), + ), + child: CircleAvatar( + radius: 26, + backgroundImage: NetworkImage(imageUrl), + ), + ), + const SizedBox(height: 6), + Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], ), - const SizedBox(height: 6), - Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ], + ), ), ); } From 0a5aa0757e6d3241ba11a59c2283f487820a7cc5 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 21:14:12 +0300 Subject: [PATCH 026/323] Updated dependencies in pubspec.lock and added internet permissions in AndroidManifest.xml --- android/app/src/main/AndroidManifest.xml | 2 ++ devtools_options.yaml | 3 +++ pubspec.lock | 16 ++++++++-------- 3 files changed, 13 insertions(+), 8 deletions(-) create mode 100644 devtools_options.yaml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c8fd234..a503dd6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + Date: Fri, 3 Apr 2026 21:29:45 +0300 Subject: [PATCH 027/323] Added localization support for multiple languages, including Russian and English, and integrated language selection in the login and country selection screens. Updated pubspec.yaml and pubspec.lock to include necessary dependencies. --- l10n.yaml | 4 + .../auth/code_confirmation_screen.dart | 13 +- lib/frontend/screens/auth/login_screen.dart | 244 +++++++-------- .../screens/auth/select_country_screen.dart | 9 +- lib/l10n/app_en.arb | 37 +++ lib/l10n/app_localizations.dart | 296 ++++++++++++++++++ lib/l10n/app_localizations_en.dart | 95 ++++++ lib/l10n/app_localizations_ru.dart | 96 ++++++ lib/l10n/app_ru.arb | 37 +++ lib/l10n/terms_of_service.dart | 11 + lib/l10n/tos_en.dart | 96 ++++++ lib/l10n/tos_ru.dart | 101 ++++++ lib/main.dart | 59 +++- pubspec.lock | 13 + pubspec.yaml | 4 + test/widget_test.dart | 44 ++- 16 files changed, 996 insertions(+), 163 deletions(-) create mode 100644 l10n.yaml create mode 100644 lib/l10n/app_en.arb create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_en.dart create mode 100644 lib/l10n/app_localizations_ru.dart create mode 100644 lib/l10n/app_ru.arb create mode 100644 lib/l10n/terms_of_service.dart create mode 100644 lib/l10n/tos_en.dart create mode 100644 lib/l10n/tos_ru.dart diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..1437ccc --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 55339be..3840751 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import '../chats/chat_list_screen.dart'; @@ -109,7 +110,10 @@ class _CodeConfirmationScreenState extends State final trackId = result.challengeTrackId; if (trackId == null) { - showCustomNotification(context, 'Ошибка: отсутствуют данные для 2FA'); + showCustomNotification( + context, + AppLocalizations.of(context)!.codeError2faMissing, + ); return; } @@ -141,6 +145,7 @@ class _CodeConfirmationScreenState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; final hasError = _errorMessage != null; return Scaffold( @@ -170,7 +175,7 @@ class _CodeConfirmationScreenState extends State ), const SizedBox(height: 12), Text( - 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.', + l10n.codeConfirmationSmsSent, style: TextStyle( color: cs.outline, fontSize: 15, @@ -326,8 +331,8 @@ class _CodeConfirmationScreenState extends State ), child: Text( _timerSeconds > 0 - ? 'Отправить повторно через $_timerSeconds сек.' - : 'Отправить код по SMS', + ? l10n.codeResendInSeconds(_timerSeconds) + : l10n.codeResendSms, ), ), ), diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index a72e298..6da104c 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -5,6 +5,8 @@ import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/countries.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/l10n/terms_of_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; @@ -77,8 +79,82 @@ class _LoginScreenState extends State { } } + String _countryDisplayName(CountryName country) { + final lang = Localizations.localeOf(context).languageCode; + return lang == 'ru' ? country.ru : country.en; + } + + void _showLanguagePicker() { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final appContext = context; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 24.0, + horizontal: 16.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8), + child: Text( + l10n.loginLanguage, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ListTile( + title: Text( + l10n.languageNameRu, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + KometApp.stateOf(appContext)?.applyLocale(const Locale('ru')); + }, + ), + ListTile( + title: Text( + l10n.languageNameEn, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + KometApp.stateOf(appContext)?.applyLocale(const Locale('en')); + }, + ), + ], + ), + ), + ); + }, + ); + } + void _showTOS(BuildContext context) { final cs = Theme.of(context).colorScheme; + final termsLocale = Localizations.localeOf(context); showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, @@ -107,7 +183,7 @@ class _LoginScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Условия использования', + AppLocalizations.of(context)!.loginTermsOfUse, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 20, @@ -147,99 +223,8 @@ class _LoginScreenState extends State { child: ListView( controller: scrollController, children: [ - //У мя вопрос а тут точно номера статей верные??? Text( - 'Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"\n\n' - '1. Статус и отношения\n' - '1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX").\n' - '1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».\n' - '1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.\n\n' - '2. Условия использования\n' - '2.1. Используя Приложение «Komet», вы:\n' - '• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)\n' - '• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;\n' - '• Принимаете на себя все риски, связанные с использованием Приложения.\n' - '2.2. Строго запрещено:\n' - '• Использовать Приложение «Komet» для распространения запрещённого контента;\n' - '• Осуществлять массовые рассылки (спам);\n' - '• Нарушать законодательство РФ и международное право;\n' - '• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».\n' - '2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.\n' - '2.4. Особенности технического взаимодействия:\n' - '• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)\n' - '• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;\n' - '• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.\n\n' - '3. Технические аспекты\n' - '3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.\n' - '3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.\n' - '3.3. Разработчики не несут ответственности за:\n' - '• Изменения в API оригинального сервиса;\n' - '• Блокировку аккаунтов пользователей;\n' - '• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».\n\n' - '4. Конфиденциальность\n' - '4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.\n' - '4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».\n' - '4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.\n\n' - '5. Ответственность и ограничения\n' - '5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.\n' - '5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.\n\n' - '6. Правовые основания\n' - '6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:\n' - '• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;\n' - '• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;\n' - '• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;\n' - '• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.\n' - '6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:\n' - '• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;\n' - '• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;\n' - '• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.\n' - '6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.\n\n' - '7. Заключительные положения\n' - '7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:\n' - '• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;\n' - '• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;\n' - '• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.\n' - '7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.\n\n' - '8. Функции безопасности и конфиденциальности\n' - '8.1. Приложение «Komet» включает инструменты защиты приватности:\n' - '• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT);\n' - '• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n' - '• Ограничение телеметрии — для минимизации передачи диагностических данных.\n' - '8.2. Данные функции:\n' - '• Направлены исключительно на защиту конфиденциальности пользователей;\n' - '• Не используются для обхода систем безопасности оригинального сервиса;\n' - '• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n' - '8.3. Разработчики не несут ответственности за:\n' - '• Блокировки, связанные с использованием инструментов конфиденциальности;\n' - '• Изменения в работе сервиса при активации данных функций.\n' - '8.4. Функции экспорта и импорта сессии\n' - '8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n' - '• Обеспечения переносимости данных между устройствами пользователя\n' - '• Резервного копирования учетных данных\n' - '• Восстановления доступа при утере устройства\n' - '8.4.2. Особенности реализации:\n' - '• Экспорт сессии осуществляется без привязки к номеру телефона\n' - '• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n' - '• Ключ шифрования известен только пользователю и не сохраняется в приложении\n' - '8.4.3. Техническая реализация экспорта сессии:\n' - '• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n' - '• Используется подмена параметров сессии для сохранения контекста аутентификации\n' - '• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n' - '• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n' - '• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n' - '• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n' - '8.4.4. Правовые основания:\n' - '• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n' - '• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n' - '• Принцип минимизации данных — сбор только необходимой для работы информации\n' - '• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n' - '• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n' - '• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n' - '8.4.5. Ограничения ответственности:\n' - '• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n' - '• Разработчики не имеют доступа к зашифрованным данным сессии\n' - '• Восстановление утерянных паролей невозможно в целях безопасности\n' - '• Ключи шифрования не хранятся в приложении и известны только пользователю', + termsOfServiceBody(termsLocale), style: TextStyle( color: cs.onSurfaceVariant, fontSize: 14, @@ -328,6 +313,7 @@ class _LoginScreenState extends State { void _showPhoneConfirmationDialog(String formattedPhone) { final screenContext = context; + final l10n = AppLocalizations.of(screenContext)!; showGeneralDialog( context: screenContext, @@ -358,7 +344,7 @@ class _LoginScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Это правильный номер?', + l10n.loginConfirmPhoneTitle, style: GoogleFonts.inter( color: cs.onSurfaceVariant, fontSize: 14, @@ -383,7 +369,7 @@ class _LoginScreenState extends State { TextButton( onPressed: () => Navigator.pop(context), child: Text( - 'Изменить', + l10n.loginEdit, style: GoogleFonts.inter( color: cs.primary, fontSize: 15, @@ -403,26 +389,24 @@ class _LoginScreenState extends State { fullPhone, ); - if (mounted) { - Navigator.push( - screenContext, - MaterialPageRoute( - builder: (context) => CodeConfirmationScreen( - phoneNumber: - '${_selectedCountry.phoneCode} $formattedPhone', - token: result.token, - ), + if (!screenContext.mounted) return; + Navigator.push( + screenContext, + MaterialPageRoute( + builder: (context) => CodeConfirmationScreen( + phoneNumber: + '${_selectedCountry.phoneCode} $formattedPhone', + token: result.token, ), - ); - } + ), + ); } catch (e) { - if (mounted) { - _showPhoneError(e.toString()); - } + if (!screenContext.mounted) return; + _showPhoneError(e.toString()); } }, child: Text( - 'Готово', + l10n.loginDone, style: GoogleFonts.inter( color: cs.primary, fontSize: 15, @@ -443,7 +427,10 @@ class _LoginScreenState extends State { void _validateAndSubmit() { if (!_isTOSRead) { - showCustomNotification(context, 'Соглащение прочитай щегол'); + showCustomNotification( + context, + AppLocalizations.of(context)!.loginReadTermsNotification, + ); return; } _showPhoneConfirmationDialog(_phoneController.text); @@ -451,6 +438,7 @@ class _LoginScreenState extends State { void _showSecurityOptions(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, @@ -470,7 +458,7 @@ class _LoginScreenState extends State { ListTile( leading: Icon(Symbols.security, color: cs.onSurface), title: Text( - 'Подделка спуфа', + l10n.loginSpoofRedacted, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, @@ -490,7 +478,7 @@ class _LoginScreenState extends State { ListTile( leading: Icon(Symbols.vpn_lock, color: cs.onSurface), title: Text( - 'Прокси', + l10n.loginProxy, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, @@ -511,6 +499,7 @@ class _LoginScreenState extends State { void _showOtherLoginMethods(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, @@ -530,7 +519,7 @@ class _LoginScreenState extends State { ListTile( leading: Icon(Symbols.qr_code_2, color: cs.onSurface), title: Text( - 'По QR code', + l10n.loginSignInWithQr, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, @@ -544,7 +533,7 @@ class _LoginScreenState extends State { ListTile( leading: Icon(Symbols.key, color: cs.onSurface), title: Text( - 'По токену', + l10n.loginSignInWithToken, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, @@ -558,7 +547,7 @@ class _LoginScreenState extends State { ListTile( leading: Icon(Symbols.description, color: cs.onSurface), title: Text( - 'По файлу сессии', + l10n.loginSignInWithSessionFile, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, @@ -580,6 +569,7 @@ class _LoginScreenState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, body: GestureDetector( @@ -617,7 +607,7 @@ class _LoginScreenState extends State { ), ), IconButton( - onPressed: () {}, + onPressed: _showLanguagePicker, icon: Icon( Symbols.language, color: cs.onSurfaceVariant, @@ -637,7 +627,7 @@ class _LoginScreenState extends State { ), const SizedBox(height: 16), Text( - 'Войдите в Komet', + l10n.loginTitle, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 32, @@ -646,7 +636,7 @@ class _LoginScreenState extends State { ), const SizedBox(height: 8), Text( - 'Проверьте код страны и введите свой\nномер телефона.', + l10n.loginSubtitle, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurfaceVariant, @@ -663,11 +653,11 @@ class _LoginScreenState extends State { onTap: _showCountryPicker, borderRadius: BorderRadius.circular(50), child: _buildInputField( - label: 'Страна', + label: l10n.loginCountry, content: Row( children: [ Text( - _selectedCountry.ru, + _countryDisplayName(_selectedCountry), style: GoogleFonts.inter( color: cs.onSurface, fontSize: 15, @@ -685,7 +675,7 @@ class _LoginScreenState extends State { ), const SizedBox(height: 40), _buildInputField( - label: 'Номер телефона', + label: l10n.loginPhoneNumber, content: Row( children: [ Text( @@ -717,7 +707,7 @@ class _LoginScreenState extends State { fontWeight: FontWeight.w400, ), decoration: InputDecoration( - hintText: '(000) 000-00-00', + hintText: l10n.loginPhoneHint, hintStyle: TextStyle( color: cs.outline, fontSize: 15, @@ -768,7 +758,7 @@ class _LoginScreenState extends State { TextButton( onPressed: () => _showOtherLoginMethods(context), child: Text( - 'Другие способы входа', + l10n.loginOtherSignInMethods, style: GoogleFonts.inter( color: cs.primary, fontSize: 14, @@ -794,8 +784,7 @@ class _LoginScreenState extends State { ), children: [ TextSpan( - text: - 'Продолжая, вы соглашаетесь с \n', + text: l10n.loginTermsIntro, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 14, @@ -804,8 +793,7 @@ class _LoginScreenState extends State { ), ), TextSpan( - text: - 'пользовательскими соглашениями', + text: l10n.loginTermsLink, style: GoogleFonts.inter( color: cs.primary, fontSize: 14, diff --git a/lib/frontend/screens/auth/select_country_screen.dart b/lib/frontend/screens/auth/select_country_screen.dart index a72335e..3c5f5de 100644 --- a/lib/frontend/screens/auth/select_country_screen.dart +++ b/lib/frontend/screens/auth/select_country_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/countries.dart'; +import 'package:komet/l10n/app_localizations.dart'; class SelectCountryScreen extends StatefulWidget { final CountryName selectedCountry; @@ -41,6 +42,8 @@ class _SelectCountryScreenState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final lang = Localizations.localeOf(context).languageCode; return Scaffold( backgroundColor: cs.surface, @@ -61,7 +64,7 @@ class _SelectCountryScreenState extends State { fontWeight: FontWeight.w400, ), decoration: InputDecoration( - hintText: 'Поиск страны...', + hintText: l10n.selectCountrySearchHint, hintStyle: GoogleFonts.inter( color: cs.onSurfaceVariant, fontSize: 18, @@ -72,7 +75,7 @@ class _SelectCountryScreenState extends State { onChanged: _filterCountries, ) : Text( - 'Выберите страну', + l10n.selectCountryTitle, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 20, @@ -114,7 +117,7 @@ class _SelectCountryScreenState extends State { ), ), title: Text( - country.ru, + lang == 'ru' ? country.ru : country.en, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..aa424d8 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,37 @@ +{ + "@@locale": "en", + "loginTitle": "Sign in to Komet", + "loginSubtitle": "Check your country code and enter your\nphone number.", + "loginCountry": "Country", + "loginPhoneNumber": "Phone number", + "loginPhoneHint": "(000) 000-00-00", + "loginOtherSignInMethods": "Other sign-in methods", + "loginTermsIntro": "By continuing, you agree to \n", + "loginTermsLink": "the terms of use", + "loginTermsOfUse": "Terms of use", + "loginConfirmPhoneTitle": "Is this the correct number?", + "loginEdit": "Change", + "loginDone": "Done", + "loginReadTermsNotification": "Please read the terms of use first", + "loginSpoofRedacted": "Spoof redaction", + "loginProxy": "Proxy", + "loginSignInWithQr": "Sign in with QR code", + "loginSignInWithToken": "Sign in with token", + "loginSignInWithSessionFile": "Sign in with session file", + "loginLanguage": "Language", + "languageNameRu": "Русский", + "languageNameEn": "English", + "selectCountryTitle": "Select country", + "selectCountrySearchHint": "Search countries…", + "codeConfirmationSmsSent": "We sent an SMS with a verification code to your phone number.", + "codeResendInSeconds": "Resend in {seconds} s.", + "@codeResendInSeconds": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "codeResendSms": "Resend code via SMS", + "codeError2faMissing": "Error: missing data for 2FA" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..b0614d5 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,296 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_ru.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('ru'), + ]; + + /// No description provided for @loginTitle. + /// + /// In en, this message translates to: + /// **'Sign in to Komet'** + String get loginTitle; + + /// No description provided for @loginSubtitle. + /// + /// In en, this message translates to: + /// **'Check your country code and enter your\nphone number.'** + String get loginSubtitle; + + /// No description provided for @loginCountry. + /// + /// In en, this message translates to: + /// **'Country'** + String get loginCountry; + + /// No description provided for @loginPhoneNumber. + /// + /// In en, this message translates to: + /// **'Phone number'** + String get loginPhoneNumber; + + /// No description provided for @loginPhoneHint. + /// + /// In en, this message translates to: + /// **'(000) 000-00-00'** + String get loginPhoneHint; + + /// No description provided for @loginOtherSignInMethods. + /// + /// In en, this message translates to: + /// **'Other sign-in methods'** + String get loginOtherSignInMethods; + + /// No description provided for @loginTermsIntro. + /// + /// In en, this message translates to: + /// **'By continuing, you agree to \n'** + String get loginTermsIntro; + + /// No description provided for @loginTermsLink. + /// + /// In en, this message translates to: + /// **'the terms of use'** + String get loginTermsLink; + + /// No description provided for @loginTermsOfUse. + /// + /// In en, this message translates to: + /// **'Terms of use'** + String get loginTermsOfUse; + + /// No description provided for @loginConfirmPhoneTitle. + /// + /// In en, this message translates to: + /// **'Is this the correct number?'** + String get loginConfirmPhoneTitle; + + /// No description provided for @loginEdit. + /// + /// In en, this message translates to: + /// **'Change'** + String get loginEdit; + + /// No description provided for @loginDone. + /// + /// In en, this message translates to: + /// **'Done'** + String get loginDone; + + /// No description provided for @loginReadTermsNotification. + /// + /// In en, this message translates to: + /// **'Please read the terms of use first'** + String get loginReadTermsNotification; + + /// No description provided for @loginSpoofRedacted. + /// + /// In en, this message translates to: + /// **'Spoof redaction'** + String get loginSpoofRedacted; + + /// No description provided for @loginProxy. + /// + /// In en, this message translates to: + /// **'Proxy'** + String get loginProxy; + + /// No description provided for @loginSignInWithQr. + /// + /// In en, this message translates to: + /// **'Sign in with QR code'** + String get loginSignInWithQr; + + /// No description provided for @loginSignInWithToken. + /// + /// In en, this message translates to: + /// **'Sign in with token'** + String get loginSignInWithToken; + + /// No description provided for @loginSignInWithSessionFile. + /// + /// In en, this message translates to: + /// **'Sign in with session file'** + String get loginSignInWithSessionFile; + + /// No description provided for @loginLanguage. + /// + /// In en, this message translates to: + /// **'Language'** + String get loginLanguage; + + /// No description provided for @languageNameRu. + /// + /// In en, this message translates to: + /// **'Русский'** + String get languageNameRu; + + /// No description provided for @languageNameEn. + /// + /// In en, this message translates to: + /// **'English'** + String get languageNameEn; + + /// No description provided for @selectCountryTitle. + /// + /// In en, this message translates to: + /// **'Select country'** + String get selectCountryTitle; + + /// No description provided for @selectCountrySearchHint. + /// + /// In en, this message translates to: + /// **'Search countries…'** + String get selectCountrySearchHint; + + /// No description provided for @codeConfirmationSmsSent. + /// + /// In en, this message translates to: + /// **'We sent an SMS with a verification code to your phone number.'** + String get codeConfirmationSmsSent; + + /// No description provided for @codeResendInSeconds. + /// + /// In en, this message translates to: + /// **'Resend in {seconds} s.'** + String codeResendInSeconds(int seconds); + + /// No description provided for @codeResendSms. + /// + /// In en, this message translates to: + /// **'Resend code via SMS'** + String get codeResendSms; + + /// No description provided for @codeError2faMissing. + /// + /// In en, this message translates to: + /// **'Error: missing data for 2FA'** + String get codeError2faMissing; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'ru'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'ru': + return AppLocalizationsRu(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..5ddbe9b --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,95 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get loginTitle => 'Sign in to Komet'; + + @override + String get loginSubtitle => + 'Check your country code and enter your\nphone number.'; + + @override + String get loginCountry => 'Country'; + + @override + String get loginPhoneNumber => 'Phone number'; + + @override + String get loginPhoneHint => '(000) 000-00-00'; + + @override + String get loginOtherSignInMethods => 'Other sign-in methods'; + + @override + String get loginTermsIntro => 'By continuing, you agree to \n'; + + @override + String get loginTermsLink => 'the terms of use'; + + @override + String get loginTermsOfUse => 'Terms of use'; + + @override + String get loginConfirmPhoneTitle => 'Is this the correct number?'; + + @override + String get loginEdit => 'Change'; + + @override + String get loginDone => 'Done'; + + @override + String get loginReadTermsNotification => 'Please read the terms of use first'; + + @override + String get loginSpoofRedacted => 'Spoof redaction'; + + @override + String get loginProxy => 'Proxy'; + + @override + String get loginSignInWithQr => 'Sign in with QR code'; + + @override + String get loginSignInWithToken => 'Sign in with token'; + + @override + String get loginSignInWithSessionFile => 'Sign in with session file'; + + @override + String get loginLanguage => 'Language'; + + @override + String get languageNameRu => 'Русский'; + + @override + String get languageNameEn => 'English'; + + @override + String get selectCountryTitle => 'Select country'; + + @override + String get selectCountrySearchHint => 'Search countries…'; + + @override + String get codeConfirmationSmsSent => + 'We sent an SMS with a verification code to your phone number.'; + + @override + String codeResendInSeconds(int seconds) { + return 'Resend in $seconds s.'; + } + + @override + String get codeResendSms => 'Resend code via SMS'; + + @override + String get codeError2faMissing => 'Error: missing data for 2FA'; +} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart new file mode 100644 index 0000000..e63e96d --- /dev/null +++ b/lib/l10n/app_localizations_ru.dart @@ -0,0 +1,96 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get loginTitle => 'Войдите в Komet'; + + @override + String get loginSubtitle => + 'Проверьте код страны и введите свой\nномер телефона.'; + + @override + String get loginCountry => 'Страна'; + + @override + String get loginPhoneNumber => 'Номер телефона'; + + @override + String get loginPhoneHint => '(000) 000-00-00'; + + @override + String get loginOtherSignInMethods => 'Другие способы входа'; + + @override + String get loginTermsIntro => 'Продолжая, вы соглашаетесь с \n'; + + @override + String get loginTermsLink => 'пользовательскими соглашениями'; + + @override + String get loginTermsOfUse => 'Условия использования'; + + @override + String get loginConfirmPhoneTitle => 'Это правильный номер?'; + + @override + String get loginEdit => 'Изменить'; + + @override + String get loginDone => 'Готово'; + + @override + String get loginReadTermsNotification => + 'Сначала прочитайте условия использования'; + + @override + String get loginSpoofRedacted => 'Подделка спуфа'; + + @override + String get loginProxy => 'Прокси'; + + @override + String get loginSignInWithQr => 'По QR code'; + + @override + String get loginSignInWithToken => 'По токену'; + + @override + String get loginSignInWithSessionFile => 'По файлу сессии'; + + @override + String get loginLanguage => 'Язык'; + + @override + String get languageNameRu => 'Русский'; + + @override + String get languageNameEn => 'English'; + + @override + String get selectCountryTitle => 'Выберите страну'; + + @override + String get selectCountrySearchHint => 'Поиск страны…'; + + @override + String get codeConfirmationSmsSent => + 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.'; + + @override + String codeResendInSeconds(int seconds) { + return 'Отправить повторно через $seconds сек.'; + } + + @override + String get codeResendSms => 'Отправить код по SMS'; + + @override + String get codeError2faMissing => 'Ошибка: отсутствуют данные для 2FA'; +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 0000000..a53ed02 --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,37 @@ +{ + "@@locale": "ru", + "loginTitle": "Войдите в Komet", + "loginSubtitle": "Проверьте код страны и введите свой\nномер телефона.", + "loginCountry": "Страна", + "loginPhoneNumber": "Номер телефона", + "loginPhoneHint": "(000) 000-00-00", + "loginOtherSignInMethods": "Другие способы входа", + "loginTermsIntro": "Продолжая, вы соглашаетесь с \n", + "loginTermsLink": "пользовательскими соглашениями", + "loginTermsOfUse": "Условия использования", + "loginConfirmPhoneTitle": "Это правильный номер?", + "loginEdit": "Изменить", + "loginDone": "Готово", + "loginReadTermsNotification": "Сначала прочитайте условия использования", + "loginSpoofRedacted": "Подделка спуфа", + "loginProxy": "Прокси", + "loginSignInWithQr": "По QR code", + "loginSignInWithToken": "По токену", + "loginSignInWithSessionFile": "По файлу сессии", + "loginLanguage": "Язык", + "languageNameRu": "Русский", + "languageNameEn": "English", + "selectCountryTitle": "Выберите страну", + "selectCountrySearchHint": "Поиск страны…", + "codeConfirmationSmsSent": "Мы отправили SMS с кодом подтверждения на ваш номер телефона.", + "codeResendInSeconds": "Отправить повторно через {seconds} сек.", + "@codeResendInSeconds": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "codeResendSms": "Отправить код по SMS", + "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA" +} diff --git a/lib/l10n/terms_of_service.dart b/lib/l10n/terms_of_service.dart new file mode 100644 index 0000000..ec8c52a --- /dev/null +++ b/lib/l10n/terms_of_service.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart'; + +import 'tos_en.dart'; +import 'tos_ru.dart'; + +String termsOfServiceBody(Locale locale) { + if (locale.languageCode == 'ru') { + return kTermsOfServiceRu; + } + return kTermsOfServiceEn; +} diff --git a/lib/l10n/tos_en.dart b/lib/l10n/tos_en.dart new file mode 100644 index 0000000..45a3f16 --- /dev/null +++ b/lib/l10n/tos_en.dart @@ -0,0 +1,96 @@ +const String kTermsOfServiceEn = r''' +Terms of use for the unofficial MAX client named "KometClient" or "Komet" + +1. Status and relationships +1.1. "Komet" (the "App") is an unofficial third-party application and is not affiliated with Communication Platform LLC (rights holder of the "MAX" service). +1.2. The App developers are not partners, employees, or affiliates of Communication Platform LLC. +1.3. All references to the "MAX" trademarks and related services belong to their respective owners. + +2. Terms of use +2.1. By using "Komet", you: +• Automatically agree to the official MAX User Agreement (https://legal.max.ru/ps) +• Understand that using an unofficial client may lead to your account being blocked by Communication Platform LLC +• Accept all risks associated with using the App +2.2. Strictly prohibited: +• Using "Komet" to distribute prohibited content +• Sending bulk messages (spam) +• Violating applicable law +• Attempting to hack or disrupt the original "MAX" service +2.3. The technical implementation follows fair-use principles and does not infringe the rights holder's exclusive rights under applicable interoperability and fair-use norms. +2.4. Technical interaction: +• "Komet" uses publicly available interaction methods with "MAX", similar to the web client (https://web.max.ru) +• Requests are made for interoperability purposes +• The developers do not circumvent technical protection measures or decompile the original software + +3. Technical aspects +3.1. "Komet" uses only publicly available methods to interact with "MAX" through official endpoints. +3.2. Requests are made under fair use for interoperability. +3.3. The developers are not liable for: +• Changes to the original service API +• Account blocks +• Functional limitations caused by Communication Platform LLC + +4. Privacy +4.1. "Komet" does not store or process users' personal data on developers' servers. +4.2. Authentication data is sent directly to Communication Platform LLC servers. +4.3. The developers do not have access to logins, passwords, chats, or other personal user data. + +5. Liability and limitations +5.1. "Komet" is provided "as is" without warranties. +5.2. The developers may discontinue support at any time without notice. + +6. Legal basis +6.1. Development and distribution of "Komet" are carried out with regard to interoperability, fair use, and publicly available information principles, including EU Directive 2019/790 on interoperability where applicable. +6.2. Interaction with "MAX" occurs only through: +• Public interfaces available via the web client +• Reverse engineering methods permitted for interoperability purposes where applicable by law +• Open interaction protocols not protected by technical protection measures +6.3. "Komet" does not circumvent technical protection measures or disrupt the normal operation of the original service. + +7. Final provisions +7.1. By using "Komet", you agree that: +• The authorized way to use "MAX" is through official clients +• Service-related claims should be directed to Communication Platform LLC +• The App developers are not liable for any direct or indirect damages +7.2. These terms may change without prior notice. + +8. Security and privacy features +8.1. "Komet" may include privacy tools: +• Session data substitution to reduce tracking via OSINT-style techniques +• Proxy connectivity for safer network access +• Reduced telemetry where applicable +8.2. These features: +• Aim to protect user privacy +• Are not intended to bypass the original service's security systems +• Are implemented with respect for privacy protections under applicable law +8.3. The developers are not liable for: +• Blocks related to privacy tools +• Service behavior when such features are enabled +8.4. Session export and import +8.4.1. "Komet" may allow exporting and importing session data for: +• Moving data between your devices +• Backing up credentials +• Restoring access if a device is lost +8.4.2. Implementation notes: +• Export may be decoupled from the phone number where technically possible +• Session data may be protected with a password and encryption (e.g. AES-256) +• Encryption keys are known only to you and are not stored by the developers +8.4.3. Technical approach: +• Export/import may use authorization tokens for service identification +• Session parameters may be adjusted to preserve authentication context +• Proxy settings may be included for a single connectivity configuration +• Imported sessions may route traffic according to your proxy settings +• The original service may not receive explicit device-change metadata beyond what the protocol normally sends +• Encryption may apply to the full exported package (session + proxy configuration) +8.4.4. Legal considerations: +• Processing is based on your consent where personal data laws require it +• Data minimization: only what is needed for operation +• Token use is not unauthorized access under applicable criminal computer-access laws +• Session handling is analogous to legitimate session persistence (e.g. cookies) +• IP masking can be a lawful way to protect personal data where applicable +8.4.5. Limitations: +• You are responsible for passwords and backups +• Developers cannot access your encrypted session exports +• Lost passwords cannot be recovered by design +• Encryption keys are not stored in the app and are known only to you +'''; diff --git a/lib/l10n/tos_ru.dart b/lib/l10n/tos_ru.dart new file mode 100644 index 0000000..7354d08 --- /dev/null +++ b/lib/l10n/tos_ru.dart @@ -0,0 +1,101 @@ +const String kTermsOfServiceRu = r''' +Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet" + +1. Статус и отношения +1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX"). +1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа». +1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям. + +2. Условия использования +2.1. Используя Приложение «Komet», вы: +• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps) +• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»; +• Принимаете на себя все риски, связанные с использованием Приложения. +2.2. Строго запрещено: +• Использовать Приложение «Komet» для распространения запрещённого контента; +• Осуществлять массовые рассылки (спам); +• Нарушать законодательство РФ и международное право; +• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX». +2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ. +2.4. Особенности технического взаимодействия: +• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru) +• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости; +• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО. + +3. Технические аспекты +3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки. +3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости. +3.3. Разработчики не несут ответственности за: +• Изменения в API оригинального сервиса; +• Блокировку аккаунтов пользователей; +• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа». + +4. Конфиденциальность +4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей. +4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа». +4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей. + +5. Ответственность и ограничения +5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности. +5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин. + +6. Правовые основания +6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с: +• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости; +• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях; +• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации; +• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ. +6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через: +• Публичные API‑интерфейсы, доступные через веб‑версию сервиса; +• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости; +• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны. +6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ. + +7. Заключительные положения +7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что: +• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов; +• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»; +• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки. +7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей. + +8. Функции безопасности и конфиденциальности +8.1. Приложение «Komet» включает инструменты защиты приватности: +• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT); +• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия; +• Ограничение телеметрии — для минимизации передачи диагностических данных. +8.2. Данные функции: +• Направлены исключительно на защиту конфиденциальности пользователей; +• Не используются для обхода систем безопасности оригинального сервиса; +• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни. +8.3. Разработчики не несут ответственности за: +• Блокировки, связанные с использованием инструментов конфиденциальности; +• Изменения в работе сервиса при активации данных функций. +8.4. Функции экспорта и импорта сессии +8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для: +• Обеспечения переносимости данных между устройствами пользователя +• Резервного копирования учетных данных +• Восстановления доступа при утере устройства +8.4.2. Особенности реализации: +• Экспорт сессии осуществляется без привязки к номеру телефона +• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256 +• Ключ шифрования известен только пользователю и не сохраняется в приложении +8.4.3. Техническая реализация экспорта сессии: +• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе +• Используется подмена параметров сессии для сохранения контекста аутентификации +• Интеграция настроек прокси для обеспечения единой конфигурации подключения +• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки +• Серверы оригинального сервиса не получают данных о смене устройства пользователя +• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг) +8.4.4. Правовые основания: +• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта +• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных) +• Принцип минимизации данных — сбор только необходимой для работы информации +• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается) +• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies) +• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152) +8.4.5. Ограничения ответственности: +• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий +• Разработчики не имеют доступа к зашифрованным данным сессии +• Восстановление утерянных паролей невозможно в целях безопасности +• Ключи шифрования не хранятся в приложении и известны только пользователю +'''; diff --git a/lib/main.dart b/lib/main.dart index c8fb446..1e20ee2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,8 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; import 'backend/modules/account.dart'; import 'backend/modules/messages.dart'; @@ -13,19 +15,65 @@ final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); +Future _loadInitialLocale() async { + final prefs = await SharedPreferences.getInstance(); + final code = prefs.getString('app_locale'); + if (code != null && (code == 'en' || code == 'ru')) { + return Locale(code); + } + final platform = WidgetsBinding.instance.platformDispatcher.locale; + if (platform.languageCode == 'en' || platform.languageCode == 'ru') { + return Locale(platform.languageCode); + } + return const Locale('ru'); +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); await AppDatabase.init(); await api.connect(); - runApp(const MyApp()); + final initialLocale = await _loadInitialLocale(); + runApp(KometApp(initialLocale: initialLocale)); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class KometApp extends StatefulWidget { + const KometApp({super.key, required this.initialLocale}); + final Locale initialLocale; + + static KometAppState? stateOf(BuildContext context) { + return context.findAncestorStateOfType(); + } + + @override + State createState() => KometAppState(); +} + +class KometAppState extends State { static const _fallbackSeed = Color(0xFFC1C4FF); - static ColorScheme _adjustScheme(ColorScheme base) { + late Locale _locale; + + @override + void initState() { + super.initState(); + _locale = widget.initialLocale; + } + + Future applyLocale(Locale locale) async { + if (!AppLocalizations.supportedLocales.any( + (l) => l.languageCode == locale.languageCode, + )) { + return; + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('app_locale', locale.languageCode); + if (mounted) { + setState(() => _locale = locale); + } + } + + ColorScheme _adjustScheme(ColorScheme base) { return base.copyWith( surface: Color.alphaBlend( base.primary.withValues(alpha: 0.05), @@ -58,6 +106,9 @@ class MyApp extends StatelessWidget { return MaterialApp( title: 'Komet', debugShowCheckedModeBanner: false, + locale: _locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, theme: ThemeData( useMaterial3: true, colorScheme: darkScheme, diff --git a/pubspec.lock b/pubspec.lock index 8948ca6..e8fa11e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -142,6 +142,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_secure_storage: dependency: "direct main" description: @@ -248,6 +253,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 244e464..2dd41b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,9 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter + intl: any # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -65,6 +68,7 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in diff --git a/test/widget_test.dart b/test/widget_test.dart index b921755..b382eb1 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,26 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:komet/main.dart'; +import 'package:komet/l10n/app_localizations.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + testWidgets('AppLocalizations Russian login title', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('ru'), + home: Builder( + builder: (context) { + return Scaffold( + body: Text(AppLocalizations.of(context)!.loginTitle), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Komet'), findsOneWidget); }); } From d860505e3de8384861f9e40cbb55dc30ff3558fb Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 21:38:59 +0300 Subject: [PATCH 028/323] Implemented country registration handling in the API, allowing for dynamic country selection in the login and country selection screens. Added parsing logic for registration countries and updated the UI to reflect allowed countries based on the API response. --- lib/backend/api.dart | 29 +++++++++++++++++++ lib/core/config/countries.dart | 9 ++++++ lib/frontend/screens/auth/login_screen.dart | 16 ++++++++-- .../screens/auth/select_country_screen.dart | 21 ++++++++++---- 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 1eeb9dd..aedf00d 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import '../core/config/config.dart'; +import '../core/config/countries.dart'; import '../core/protocol/opcode_map.dart'; import '../core/protocol/packet.dart'; import '../core/transport/connection.dart'; @@ -33,6 +34,11 @@ class Api { Map? get userAgent => _userAgent; + List? _registrationCountries; + + List get registrationCountries => + _registrationCountries ?? allCountries; + Stream get stateStream => _stateController.stream; SessionState get state => _sessionState; @@ -76,6 +82,7 @@ class Api { try { final response = await sendHandshake(); if (response.isOk) { + _registrationCountries = _parseRegistrationCountries(response.payload); _setSessionState(SessionState.online); _startPinging(); logger.i('Сессия онлайн, хэндшейк ок'); @@ -230,6 +237,28 @@ class Api { }); } + static List? _parseRegistrationCountries(dynamic payload) { + if (payload is! Map) return null; + final raw = payload['reg-country-code']; + if (raw is! List || raw.isEmpty) return null; + final codes = []; + for (final e in raw) { + if (e is String && e.isNotEmpty) codes.add(e.toUpperCase()); + } + if (codes.isEmpty) return null; + var list = countriesInServerOrder(codes); + if (list.isEmpty) return null; + + final loc = payload['location']; + if (loc is String && loc.length == 2) { + final home = countriesByCode[loc.toUpperCase()]; + if (home != null && !list.any((c) => c.code == home.code)) { + list = [home, ...list]; + } + } + return list; + } + void _scheduleReconnect() { if (_reconnectAttempts >= ServerConfig.maxReconnectAttempts) { logger.e('Лимит попыток реконнекта'); diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart index d19d055..e87262e 100644 --- a/lib/core/config/countries.dart +++ b/lib/core/config/countries.dart @@ -33,6 +33,15 @@ final Map countriesByCode = { for (final country in allCountries) country.code: country, }; +List countriesInServerOrder(Iterable codes) { + final out = []; + for (final raw in codes) { + final c = countriesByCode[raw.toUpperCase()]; + if (c != null) out.add(c); + } + return out; +} + /// Пример использования: /// ```dart /// final country = exampleCountryLookup('RU'); diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 6da104c..579f250 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -33,9 +33,19 @@ class _LoginScreenState extends State { void initState() { super.initState(); _selectedCountry = countriesByCode['RU'] ?? allCountries.first; + _clampCountryToAllowed(); _checkTOS(); } + void _clampCountryToAllowed() { + final allowed = api.registrationCountries; + if (allowed.any((c) => c.code == _selectedCountry.code)) return; + _selectedCountry = allowed.firstWhere( + (c) => c.code == 'RU', + orElse: () => allowed.first, + ); + } + @override void dispose() { _phoneErrorTimer?.cancel(); @@ -66,8 +76,10 @@ class _LoginScreenState extends State { final result = await Navigator.push( context, MaterialPageRoute( - builder: (context) => - SelectCountryScreen(selectedCountry: _selectedCountry), + builder: (context) => SelectCountryScreen( + selectedCountry: _selectedCountry, + countries: api.registrationCountries, + ), ), ); if (result != null) { diff --git a/lib/frontend/screens/auth/select_country_screen.dart b/lib/frontend/screens/auth/select_country_screen.dart index 3c5f5de..bf4bd03 100644 --- a/lib/frontend/screens/auth/select_country_screen.dart +++ b/lib/frontend/screens/auth/select_country_screen.dart @@ -6,8 +6,13 @@ import 'package:komet/l10n/app_localizations.dart'; class SelectCountryScreen extends StatefulWidget { final CountryName selectedCountry; + final List countries; - const SelectCountryScreen({super.key, required this.selectedCountry}); + SelectCountryScreen({ + super.key, + required this.selectedCountry, + List? countries, + }) : countries = countries ?? allCountries; @override State createState() => _SelectCountryScreenState(); @@ -16,7 +21,13 @@ class SelectCountryScreen extends StatefulWidget { class _SelectCountryScreenState extends State { bool _isSearching = false; final TextEditingController _searchController = TextEditingController(); - List _filteredCountries = allCountries; + late List _filteredCountries; + + @override + void initState() { + super.initState(); + _filteredCountries = widget.countries; + } @override void dispose() { @@ -27,10 +38,10 @@ class _SelectCountryScreenState extends State { void _filterCountries(String query) { setState(() { if (query.isEmpty) { - _filteredCountries = allCountries; + _filteredCountries = widget.countries; } else { final q = query.toLowerCase(); - _filteredCountries = allCountries.where((c) { + _filteredCountries = widget.countries.where((c) { return c.ru.toLowerCase().contains(q) || c.en.toLowerCase().contains(q) || c.phoneCode.contains(q); @@ -90,7 +101,7 @@ class _SelectCountryScreenState extends State { _isSearching = !_isSearching; if (!_isSearching) { _searchController.clear(); - _filteredCountries = allCountries; + _filteredCountries = widget.countries; } }); }, From 4575bf4c415d2b2e8f5b98bd1b1f09735c1ec438 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 22:32:20 +0300 Subject: [PATCH 029/323] Refactor logging utility to support dynamic log levels and enhanced log formatting. Introduced KometLogPrinter for custom log output, including color coding and structured message formatting. Updated logger initialization to utilize new log level and filter functions based on release mode. --- lib/core/utils/logger.dart | 147 ++++++++++++++++++++++++++++++++++--- 1 file changed, 138 insertions(+), 9 deletions(-) diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 84ae57d..a57ef72 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -1,13 +1,142 @@ -import 'package:logger/logger.dart'; +import 'dart:convert'; + import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; + +Level _minimumLogLevel() { + const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: ''); + switch (raw.toLowerCase()) { + case 'trace': + return Level.trace; + case 'debug': + return Level.debug; + case 'info': + return Level.info; + case 'warning': + case 'warn': + return Level.warning; + case 'error': + return Level.error; + case 'fatal': + return Level.fatal; + case 'off': + return Level.off; + default: + break; + } + if (kReleaseMode) { + return Level.info; + } + return Level.trace; +} + +LogFilter _logFilter() { + if (kReleaseMode) { + return ProductionFilter(); + } + return DevelopmentFilter(); +} final logger = Logger( - level: kReleaseMode ? Level.info : Level.all, - printer: PrettyPrinter( - methodCount: 0, - errorMethodCount: 5, - lineLength: 80, - colors: true, - printEmojis: true, - ), + filter: _logFilter(), + level: _minimumLogLevel(), + printer: KometLogPrinter(), ); + +int _importanceSortKey(Level level) { + final v = level.value; + if (v >= 5999) { + return 0; + } + if (v >= 5000) { + return 1; + } + if (v >= 4000) { + return 2; + } + if (v >= 3000) { + return 3; + } + if (v >= 2000) { + return 4; + } + return 5; +} + +String _levelLetter(Level level) { + final v = level.value; + if (v >= 5999) { + return 'F'; + } + if (v >= 5000) { + return 'E'; + } + if (v >= 4000) { + return 'W'; + } + if (v >= 3000) { + return 'I'; + } + if (v >= 2000) { + return 'D'; + } + return 'T'; +} + +AnsiColor _levelColor(Level level) { + final v = level.value; + if (v >= 5999) { + return const AnsiColor.fg(199); + } + if (v >= 5000) { + return const AnsiColor.fg(196); + } + if (v >= 4000) { + return const AnsiColor.fg(208); + } + if (v >= 3000) { + return const AnsiColor.fg(12); + } + if (v >= 2000) { + return const AnsiColor.none(); + } + return AnsiColor.fg(AnsiColor.grey(0.5)); +} + +class KometLogPrinter extends LogPrinter { + KometLogPrinter({this.colors = true}); + + final bool colors; + + @override + List log(LogEvent event) { + final lines = []; + final t = event.time; + final timeStr = + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}.${t.millisecond.toString().padLeft(3, '0')}'; + final ord = _importanceSortKey(event.level).toString().padLeft(2, '0'); + final letter = _levelLetter(event.level); + final label = colors ? _levelColor(event.level)(letter) : letter; + final msg = _stringifyMessage(event.message); + lines.add('$ord|$timeStr $label $msg'); + if (event.error != null) { + lines.add('${' ' * 3}| ${event.error}'); + } + if (event.stackTrace != null && event.level.value >= 5000) { + final st = event.stackTrace.toString().split('\n'); + const limit = 12; + for (var i = 0; i < st.length && i < limit; i++) { + lines.add('${' ' * 3}| ${st[i]}'); + } + } + return lines; + } + + String _stringifyMessage(dynamic message) { + final finalMessage = message is Function ? message() : message; + if (finalMessage is Map || finalMessage is Iterable) { + return const JsonEncoder.withIndent(null).convert(finalMessage); + } + return finalMessage.toString(); + } +} From b796647bdc3ad5c691114fa41ec172606965b42f Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 22:49:15 +0300 Subject: [PATCH 030/323] Refactor story closing logic in chat list screen to use a timestamp for layout settling --- .../screens/chats/chat_list_screen.dart | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index d25bf4e..d3ee919 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -38,7 +38,7 @@ class _ChatListScreenState extends State double _closeAnimBegin = 0.0; bool _storiesAnimClosing = false; bool _storiesDockedOpen = false; - double _storiesCloseIntentAccum = 0.0; + DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); ProfileData? _profile; List _chats = []; SessionState _sessionState = SessionState.disconnected; @@ -219,6 +219,9 @@ class _ChatListScreenState extends State } else { _pullRatio = 1.0; _storiesDockedOpen = true; + _storiesRevealLayoutSettleUntil = DateTime.now().add( + const Duration(milliseconds: 520), + ); } }); } @@ -235,6 +238,9 @@ class _ChatListScreenState extends State _pullRatio = 1.0; _storiesDockedOpen = true; }); + _storiesRevealLayoutSettleUntil = DateTime.now().add( + const Duration(milliseconds: 520), + ); return; } _revealAnimBegin = from; @@ -252,7 +258,6 @@ class _ChatListScreenState extends State return; } if (_storiesAnimClosing && _storiesRevealController.isAnimating) return; - _storiesCloseIntentAccum = 0.0; _storiesRevealController.stop(); _storiesAnimClosing = true; final from = _pullRatio.clamp(0.0, 1.0); @@ -277,26 +282,20 @@ class _ChatListScreenState extends State if (n is! ScrollUpdateNotification) return false; if (!_scrollController.hasClients) return false; if (!_storiesDockedOpen || _storiesRevealController.isAnimating) { - _storiesCloseIntentAccum = 0.0; + return false; + } + if (!DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + return false; + } + if (n.dragDetails == null) { return false; } final m = n.metrics; if (m.axis != Axis.vertical) return false; - if (m.pixels > m.minScrollExtent + 1.0) { - _storiesCloseIntentAccum = 0.0; - return false; - } + if (m.pixels > m.minScrollExtent + 1.0) return false; final d = n.scrollDelta; - if (d == null) return false; - if (d > 0) { - _storiesCloseIntentAccum = 0.0; - return false; - } - _storiesCloseIntentAccum += -d; - if (_storiesCloseIntentAccum >= _kStoriesPullTriggerPx) { - _storiesCloseIntentAccum = 0.0; - _startStoriesAutoClose(); - } + if (d == null || d <= 0) return false; + _startStoriesAutoClose(); return false; } @@ -324,6 +323,11 @@ class _ChatListScreenState extends State } } } else { + if (_storiesDockedOpen && + offset > 12 && + DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + _startStoriesAutoClose(); + } if (_storiesDockedOpen || _storiesRevealController.isAnimating) { return; } From 60c8d19ed46e5cd7a20b7c390f0c437b25001b65 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 22:56:41 +0300 Subject: [PATCH 031/323] Add pinned chats header to chat list screen with dynamic story display --- .../screens/chats/chat_list_screen.dart | 608 +++++++++--------- 1 file changed, 292 insertions(+), 316 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index d3ee919..273f7b0 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -38,7 +38,8 @@ class _ChatListScreenState extends State double _closeAnimBegin = 0.0; bool _storiesAnimClosing = false; bool _storiesDockedOpen = false; - DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); + DateTime _storiesRevealLayoutSettleUntil = + DateTime.fromMillisecondsSinceEpoch(0); ProfileData? _profile; List _chats = []; SessionState _sessionState = SessionState.disconnected; @@ -363,6 +364,258 @@ class _ChatListScreenState extends State }); } + Widget _buildPinnedChatsHeader(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ColoredBox( + color: cs.surface, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRect( + clipBehavior: Clip.hardEdge, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _shouldCollapseSearch + ? const SizedBox(width: double.infinity, height: 0) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (_pullRatio < 0.8) + Opacity( + opacity: 1.0 - _pullRatio, + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only(right: 8), + child: Stack( + children: [ + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=dasha', + 0, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=mastika', + 1, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=stas', + 2, + ), + ], + ), + ), + ), + Text( + _sessionState == SessionState.online + ? (_profile?.firstName ?? 'Чат') + : 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, + ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Кнопка 1', + Symbols.settings, + ), + _buildPopupMenuItem( + 2, + 'Кнопка 2', + Symbols.notifications, + ), + _buildPopupMenuItem( + 3, + 'Кнопка 3', + Symbols.shield, + ), + _buildPopupMenuItem( + 4, + 'Кнопка 4', + Symbols.info, + ), + ], + ), + ], + ), + ), + SizedBox( + height: 96 * _pullRatio, + child: Opacity( + opacity: _pullRatio, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + children: [ + _buildStoryItem( + 'Даша', + 'https://i.pravatar.cc/150?u=dasha', + true, + ), + _buildStoryItem( + 'Мастика', + 'https://i.pravatar.cc/150?u=mastika', + false, + ), + ], + ), + ), + ), + if (_showCacheWarning) + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: cs.errorContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.error.withOpacity(0.2), + ), + ), + child: Row( + children: [ + Icon( + Symbols.cloud_off, + size: 18, + color: cs.error, + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Ошибка соединения, сейчас вы смотрите КЕШ', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + child: Container( + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + height: 48, + color: cs.surface, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + ui.PointerDeviceKind.touch, + ui.PointerDeviceKind.mouse, + ui.PointerDeviceKind.trackpad, + }, + ), + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + physics: const BouncingScrollPhysics(), + children: [ + _buildFolderChip('Все чаты'), + const SizedBox(width: 8), + _buildFolderChip('Контакты'), + const SizedBox(width: 8), + _buildFolderChip('Пидоры'), + const SizedBox(width: 8), + _buildFolderChip('Каналы'), + const SizedBox(width: 8), + _buildFolderChip('Группы'), + const SizedBox(width: 8), + _buildFolderChip('Боты'), + const SizedBox(width: 8), + _buildFolderChip('Избранное'), + const SizedBox(width: 8), + _buildFolderChip('Архив'), + ], + ), + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -391,326 +644,49 @@ class _ChatListScreenState extends State }, child: NotificationListener( onNotification: _onStoriesScrollNotification, - child: CustomScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverToBoxAdapter( - child: ClipRect( - clipBehavior: Clip.hardEdge, - child: AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: _shouldCollapseSearch - ? const SizedBox( - width: double.infinity, - height: 0, - ) - : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 12, - 20, - 2, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - if (_pullRatio < 0.8) - Opacity( - opacity: 1.0 - _pullRatio, - child: Container( - width: - 50 * - (1.0 - _pullRatio), - height: 32, - margin: - const EdgeInsets.only( - right: 8, - ), - child: Stack( - children: [ - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=dasha', - 0, - ), - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=mastika', - 1, - ), - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=stas', - 2, - ), - ], - ), - ), - ), - Text( - _sessionState == - SessionState.online - ? (_profile - ?.firstName ?? - 'Чат') - : 'Подключение...', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: - FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], - ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(16), - ), - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Кнопка 1', - Symbols.settings, - ), - _buildPopupMenuItem( - 2, - 'Кнопка 2', - Symbols.notifications, - ), - _buildPopupMenuItem( - 3, - 'Кнопка 3', - Symbols.shield, - ), - _buildPopupMenuItem( - 4, - 'Кнопка 4', - Symbols.info, - ), - ], - ), - ], - ), - ), - SizedBox( - height: 96 * _pullRatio, - child: Opacity( - opacity: _pullRatio, - child: ListView( - scrollDirection: Axis.horizontal, - padding: - const EdgeInsets.symmetric( - horizontal: 20, - ), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, - ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], - ), - ), - ), - if (_showCacheWarning) - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 0, - 20, - 8, - ), - child: Container( - padding: - const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - color: cs.errorContainer - .withOpacity(0.3), - borderRadius: - BorderRadius.circular(12), - border: Border.all( - color: cs.error.withOpacity( - 0.2, - ), - ), - ), - child: Row( - children: [ - Icon( - Symbols.cloud_off, - size: 18, - color: cs.error, - ), - const SizedBox(width: 12), - const Expanded( - child: Text( - 'Ошибка соединения, сейчас вы смотрите КЕШ', - style: TextStyle( - fontSize: 12, - fontWeight: - FontWeight.w500, - ), - ), - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 2, - 20, - 8, - ), - child: Container( - height: 44, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: - BorderRadius.circular(50), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, - ), - border: InputBorder.none, - isDense: true, - contentPadding: - EdgeInsets.zero, - ), - ), - ), - ], - ), - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPinnedChatsHeader(context), + Expanded( + child: CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), ), - ), - ), - SliverPadding( - padding: EdgeInsets.zero, - sliver: SliverToBoxAdapter( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - height: 48, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - ui.PointerDeviceKind.touch, - ui.PointerDeviceKind.mouse, - ui.PointerDeviceKind.trackpad, - }, - ), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, - ), - physics: const BouncingScrollPhysics(), - children: [ - _buildFolderChip('Все чаты'), - const SizedBox(width: 8), - _buildFolderChip('Контакты'), - const SizedBox(width: 8), - _buildFolderChip('Пидоры'), - const SizedBox(width: 8), - _buildFolderChip('Каналы'), - const SizedBox(width: 8), - _buildFolderChip('Группы'), - const SizedBox(width: 8), - _buildFolderChip('Боты'), - const SizedBox(width: 8), - _buildFolderChip('Избранное'), - const SizedBox(width: 8), - _buildFolderChip('Архив'), - ], + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && + chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, + childCount: _isInitialLoading + ? 10 + : _chats.length, ), ), - ), + const SliverPadding( + padding: EdgeInsets.only(bottom: 100), + ), + ], ), ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && - chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, - childCount: _isInitialLoading ? 10 : _chats.length, - ), - ), - const SliverPadding( - padding: EdgeInsets.only(bottom: 100), - ), ], ), ), From 7485f0a919c95bace9be4baf77c78341f8f0b017 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 23:24:32 +0300 Subject: [PATCH 032/323] Enhance chat list screen with navigation animation and dynamic tab selection. Introduced animation controller for smoother transitions and implemented drag handling for the bottom navigation bar. --- .../screens/chats/chat_list_screen.dart | 767 +++++++++++------- 1 file changed, 494 insertions(+), 273 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 273f7b0..78d1aaf 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -26,6 +26,12 @@ class _ChatListScreenState extends State with TickerProviderStateMixin { String _selectedCategory = 'Все чаты'; int _currentNavIndex = 0; + bool _navDragging = false; + double _navDragDx = 0; + double _navDragBaseLeft = 0; + late AnimationController _navPageAnimController; + double _navPageAnimStart = 0; + double _navPageAnimEnd = 0; bool _isFabOpen = false; bool _showCacheWarning = false; late AnimationController _fabController; @@ -83,6 +89,13 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); + _navPageAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + value: 1.0, + )..addListener(() { + if (mounted) setState(() {}); + }); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), @@ -345,6 +358,7 @@ class _ChatListScreenState extends State void dispose() { _stateSub?.cancel(); _fabController.dispose(); + _navPageAnimController.dispose(); _storiesRevealController ..removeListener(_onStoriesRevealTick) ..removeStatusListener(_onStoriesRevealStatus) @@ -353,6 +367,41 @@ class _ChatListScreenState extends State super.dispose(); } + double _effectivePageNavRowT({ + required double inactiveWidth, + required double Function(int index) bubbleLeftForIndex, + }) { + if (_navDragging) { + final left = (_navDragBaseLeft + _navDragDx) + .clamp(bubbleLeftForIndex(0), bubbleLeftForIndex(3)); + return ((left - 4) / inactiveWidth).clamp(0.0, 3.0); + } + if (_navPageAnimController.isAnimating) { + final t = + Curves.easeOutCubic.transform(_navPageAnimController.value); + return ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; + } + return _currentNavIndex.toDouble(); + } + + void _onNavTabSelected(int index) { + if (index == _currentNavIndex && !_navPageAnimController.isAnimating) { + return; + } + double fromT; + if (_navPageAnimController.isAnimating) { + final t = + Curves.easeOutCubic.transform(_navPageAnimController.value); + fromT = ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; + } else { + fromT = _currentNavIndex.toDouble(); + } + _navPageAnimStart = fromT; + _navPageAnimEnd = index.toDouble(); + setState(() => _currentNavIndex = index); + _navPageAnimController.forward(from: 0); + } + void _toggleFab() { setState(() { _isFabOpen = !_isFabOpen; @@ -616,6 +665,252 @@ class _ChatListScreenState extends State ); } + Widget _buildChatsTabBody() { + return Listener( + onPointerSignal: (pointerSignal) { + if (pointerSignal is PointerScrollEvent) { + if (_scrollController.hasClients && _scrollController.offset <= 0) { + if (pointerSignal.scrollDelta.dy < 0) { + _startStoriesAutoReveal(max(_pullRatio, 0.18)); + } else if (pointerSignal.scrollDelta.dy > 0 && _pullRatio > 0) { + _startStoriesAutoClose(); + } + } + } + }, + child: NotificationListener( + onNotification: _onStoriesScrollNotification, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPinnedChatsHeader(context), + Expanded( + child: CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, + childCount: + _isInitialLoading ? 10 : _chats.length, + ), + ), + const SliverPadding( + padding: EdgeInsets.only(bottom: 100), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDockedBottomNav(ColorScheme cs, double navInnerW) { + final totalWeight = 5.2; + final unitWidth = navInnerW / totalWeight; + final activeWidth = unitWidth * 2.2; + final inactiveWidth = unitWidth * 1.0; + + double bubbleLeftForIndex(int index) { + double lo = 0; + for (int i = 0; i < index; i++) { + lo += inactiveWidth; + } + return lo + 4; + } + + final leftOffset = bubbleLeftForIndex(_currentNavIndex); + final bubbleW = activeWidth - 8; + final minBubbleLeft = bubbleLeftForIndex(0); + final maxBubbleLeft = bubbleLeftForIndex(3); + + final bubbleLeft = _navDragging + ? (_navDragBaseLeft + _navDragDx) + .clamp(minBubbleLeft, maxBubbleLeft) + : leftOffset; + + final navRowT = + ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); + + double navInterpolatedWidth(int tabIndex, double rowT) { + final rt = rowT.clamp(0.0, 3.0); + final i0 = rt.floor().clamp(0, 3); + final i1 = rt.ceil().clamp(0, 3); + final frac = i0 == i1 ? 0.0 : (rt - i0); + double at(int sel, int tab) => + (tab == sel) ? (activeWidth - 0.5) : (inactiveWidth - 0.5); + return at(i0, tabIndex) + (at(i1, tabIndex) - at(i0, tabIndex)) * frac; + } + + int indexForBubbleLeft(double left) { + final cx = left + bubbleW / 2; + var best = 0; + var bestD = double.infinity; + for (var i = 0; i < 4; i++) { + final c = bubbleLeftForIndex(i) + bubbleW / 2; + final d = (c - cx).abs(); + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; + } + + return AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + left: 8, + right: 8, + bottom: _isSelectionMode ? -100 : 10.0, + child: RepaintBoundary( + child: Container( + height: 68, + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(34), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) { + if (_isSelectionMode) return; + _navPageAnimController.stop(); + _navPageAnimController.value = 1.0; + setState(() { + _navDragging = true; + _navDragDx = 0; + _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); + }); + }, + onHorizontalDragUpdate: (details) { + if (!_navDragging) return; + setState(() { + _navDragDx += details.delta.dx; + }); + }, + onHorizontalDragEnd: (_) { + if (!_navDragging) return; + final left = (_navDragBaseLeft + _navDragDx) + .clamp(minBubbleLeft, maxBubbleLeft); + final next = indexForBubbleLeft(left); + setState(() { + _currentNavIndex = next; + _navDragging = false; + _navDragDx = 0; + }); + }, + onHorizontalDragCancel: () { + if (!_navDragging) return; + setState(() { + _navDragging = false; + _navDragDx = 0; + }); + }, + child: Stack( + children: [ + AnimatedPositioned( + duration: _navDragging + ? Duration.zero + : const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + left: bubbleLeft, + top: 8, + bottom: 8, + width: bubbleW, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(26), + ), + ), + ), + Row( + children: List.generate(4, (index) { + IconData icon; + String label; + switch (index) { + case 0: + icon = Symbols.chat_bubble; + label = 'Чаты'; + break; + case 1: + icon = Symbols.call; + label = 'Звонки'; + break; + case 2: + icon = Symbols.person_pin; + label = 'Контакты'; + break; + default: + icon = Symbols.settings; + label = 'Настройки'; + } + + final isSelected = _currentNavIndex == index; + final visualSel = navRowT.round().clamp(0, 3); + return AnimatedContainer( + duration: _navDragging + ? Duration.zero + : const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + width: _navDragging + ? navInterpolatedWidth(index, navRowT) + : (isSelected + ? (activeWidth - 0.5) + : (inactiveWidth - 0.5)), + child: ClipRRect( + borderRadius: BorderRadius.circular(26), + child: _buildNavItem( + index, + icon, + label, + selectedOverride: _navDragging + ? (index == visualSel) + : null, + instant: _navDragging, + ), + ), + ); + }), + ), + ], + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -623,286 +918,201 @@ class _ChatListScreenState extends State backgroundColor: cs.surface, body: SafeArea( bottom: false, - child: Stack( - children: [ - IndexedStack( - index: _currentNavIndex, + child: LayoutBuilder( + builder: (context, constraints) { + final pageW = constraints.maxWidth; + final pageH = constraints.maxHeight; + final navInnerW = pageW - 20; + final totalWeight = 5.2; + final unitWidth = navInnerW / totalWeight; + final inactiveWidth = unitWidth * 1.0; + double bubbleLeftForPageT(int index) { + double lo = 0; + for (int i = 0; i < index; i++) { + lo += inactiveWidth; + } + return lo + 4; + } + + final pageDisplayT = _effectivePageNavRowT( + inactiveWidth: inactiveWidth, + bubbleLeftForIndex: bubbleLeftForPageT, + ); + + final showChatsFab = !_isSelectionMode && + (_navDragging || _navPageAnimController.isAnimating + ? pageDisplayT < 1.0 + : _currentNavIndex == 0); + + return Stack( children: [ - Listener( - onPointerSignal: (pointerSignal) { - if (pointerSignal is PointerScrollEvent) { - if (_scrollController.hasClients && - _scrollController.offset <= 0) { - if (pointerSignal.scrollDelta.dy < 0) { - _startStoriesAutoReveal(max(_pullRatio, 0.18)); - } else if (pointerSignal.scrollDelta.dy > 0 && - _pullRatio > 0) { - _startStoriesAutoClose(); - } - } - } - }, - child: NotificationListener( - onNotification: _onStoriesScrollNotification, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildPinnedChatsHeader(context), - Expanded( - child: CustomScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && - chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, - childCount: _isInitialLoading - ? 10 - : _chats.length, + ClipRect( + child: SizedBox( + width: pageW, + height: pageH, + child: UnconstrainedBox( + constrainedAxis: Axis.vertical, + alignment: Alignment.topLeft, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: pageW * 4, + height: pageH, + child: Transform.translate( + offset: Offset(-pageDisplayT * pageW, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: _buildChatsTabBody(), ), ), - const SliverPadding( - padding: EdgeInsets.only(bottom: 100), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const CallsTab(), + ), + ), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const ContactsTab(), + ), + ), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const SettingsTab(), + ), ), ], ), ), + ), + ), + ), + ), + _buildDockedBottomNav(cs, navInnerW), + ListenableBuilder( + listenable: _fabController, + builder: (context, child) { + final double val = Curves.easeOutCubic.transform( + _fabController.value, + ); + return Stack( + clipBehavior: Clip.none, + children: [ + if (_fabController.value > 0) + Positioned.fill( + child: GestureDetector( + onTap: _toggleFab, + behavior: HitTestBehavior.opaque, + child: Container( + color: Colors.black.withValues(alpha: val * 0.2), + ), + ), + ), + if (showChatsFab) ...[ + if (_fabController.value > 0) + Positioned( + right: 20, + bottom: 90 + 74, + child: RepaintBoundary( + child: Transform.scale( + scale: val, + alignment: Alignment.bottomRight, + child: Opacity( + opacity: val > 0.5 ? (val - 0.5) * 2 : 0, + child: _buildFabMenu(), + ), + ), + ), + ), + Positioned( + right: 20, + bottom: 90, + child: FloatingActionButton( + onPressed: _toggleFab, + backgroundColor: cs.primaryContainer, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Transform.rotate( + angle: val * (pi / 4), + child: Icon( + Symbols.add, + color: cs.onPrimaryContainer, + size: 28, + weight: 400, + ), + ), + ), + ), + ], + ], + ); + }, + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + top: _isSelectionMode ? 0 : -80, + left: 0, + right: 0, + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: cs.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _clearSelection, + ), + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.archive, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.volume_off, color: cs.onSurface), + onPressed: () {}, + ), ], ), ), ), - const CallsTab(), - const ContactsTab(), - const SettingsTab(), ], - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - left: 8, - right: 8, - bottom: _isSelectionMode ? -100 : 10.0, - child: RepaintBoundary( - child: Container( - height: 68, - padding: const EdgeInsets.symmetric(horizontal: 2), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(34), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: LayoutBuilder( - builder: (context, constraints) { - double totalWidth = constraints.maxWidth; - - double totalWeight = 5.2; - double unitWidth = totalWidth / totalWeight; - double activeWidth = unitWidth * 2.2; - double inactiveWidth = unitWidth * 1.0; - - double leftOffset = 0; - for (int i = 0; i < _currentNavIndex; i++) { - leftOffset += inactiveWidth; - } - - return Stack( - children: [ - AnimatedPositioned( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - left: leftOffset + 4, - top: 8, - bottom: 8, - width: activeWidth - 8, - child: Container( - decoration: BoxDecoration( - color: cs.primary, - borderRadius: BorderRadius.circular(26), - ), - ), - ), - Row( - children: List.generate(4, (index) { - IconData icon; - String label; - switch (index) { - case 0: - icon = Symbols.chat_bubble; - label = 'Чаты'; - break; - case 1: - icon = Symbols.call; - label = 'Звонки'; - break; - case 2: - icon = Symbols.person_pin; - label = 'Контакты'; - break; - default: - icon = Symbols.settings; - label = 'Настройки'; - } - - bool isSelected = _currentNavIndex == index; - return AnimatedContainer( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - width: isSelected - ? (activeWidth - 0.5) - : (inactiveWidth - 0.5), - child: ClipRRect( - borderRadius: BorderRadius.circular(26), - child: _buildNavItem(index, icon, label), - ), - ); - }), - ), - ], - ); - }, - ), - ), - ), - ), - AnimatedBuilder( - animation: _fabController, - builder: (context, child) { - final double val = Curves.easeOutCubic.transform( - _fabController.value, - ); - return Stack( - clipBehavior: Clip.none, - children: [ - if (_fabController.value > 0) - Positioned.fill( - child: GestureDetector( - onTap: _toggleFab, - behavior: HitTestBehavior.opaque, - child: Container( - color: Colors.black.withValues(alpha: val * 0.2), - ), - ), - ), - if (!_isSelectionMode && _currentNavIndex == 0) ...[ - if (_fabController.value > 0) - Positioned( - right: 20, - bottom: 90 + 74, - child: RepaintBoundary( - child: Transform.scale( - scale: val, - alignment: Alignment.bottomRight, - child: Opacity( - opacity: val > 0.5 ? (val - 0.5) * 2 : 0, - child: _buildFabMenu(), - ), - ), - ), - ), - Positioned( - right: 20, - bottom: 90, - child: FloatingActionButton( - onPressed: _toggleFab, - backgroundColor: cs.primaryContainer, - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Transform.rotate( - angle: val * (pi / 4), - child: Icon( - Symbols.add, - color: cs.onPrimaryContainer, - size: 28, - weight: 400, - ), - ), - ), - ), - ], - ], - ); - }, - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - top: _isSelectionMode ? 0 : -80, - left: 0, - right: 0, - child: Container( - height: 64, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: cs.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: _clearSelection, - ), - const SizedBox(width: 8), - Text( - _selectedChats.length.toString(), - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon(Symbols.archive, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon(Symbols.volume_off, color: cs.onSurface), - onPressed: () {}, - ), - ], - ), - ), - ), - ], + ); + }, ), ), ); @@ -1142,11 +1352,22 @@ class _ChatListScreenState extends State ); } - Widget _buildNavItem(int index, IconData icon, String label) { + Widget _buildNavItem( + int index, + IconData icon, + String label, { + bool? selectedOverride, + bool instant = false, + }) { final cs = Theme.of(context).colorScheme; - bool isSelected = _currentNavIndex == index; + final bool isSelected = + selectedOverride ?? (_currentNavIndex == index); + final Duration animDur = + instant ? Duration.zero : const Duration(milliseconds: 350); + final Duration opacityDur = + instant ? Duration.zero : const Duration(milliseconds: 200); return GestureDetector( - onTap: () => setState(() => _currentNavIndex = index), + onTap: () => _onNavTabSelected(index), behavior: HitTestBehavior.opaque, child: Center( child: FittedBox( @@ -1163,11 +1384,11 @@ class _ChatListScreenState extends State fill: isSelected ? 1.0 : 0.0, ), AnimatedContainer( - duration: const Duration(milliseconds: 350), + duration: animDur, curve: Curves.easeOutCubic, width: isSelected ? null : 0, child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), + duration: opacityDur, opacity: isSelected ? 1.0 : 0.0, child: Row( mainAxisSize: MainAxisSize.min, From 54c2c9096498d2311265f4793c814444618914cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Fri, 3 Apr 2026 21:42:53 +0700 Subject: [PATCH 033/323] =?UTF-8?q?=D0=AD=D0=BA=D1=80=D0=B0=D0=BD=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20+=20danon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 47 ++ .../screens/profile/devices_screen.dart | 600 ++++++++++++++++++ .../screens/profile/settings_tab.dart | 28 +- 3 files changed, 669 insertions(+), 6 deletions(-) create mode 100644 lib/frontend/screens/profile/devices_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index a9af71a..82a6505 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -112,6 +112,35 @@ class LoginSyncParams { } } +class SessionInfo { + final int? id; + final String client; + final String location; + final bool current; + final int time; + final String info; + + const SessionInfo({ + this.id, + required this.client, + required this.location, + required this.current, + required this.time, + required this.info, + }); + + factory SessionInfo.fromMap(Map map) { + return SessionInfo( + id: map['id'], + client: map['client'] ?? '', + location: map['location'] ?? '', + current: map['current'] ?? false, + time: map['time'] ?? 0, + info: map['info'] ?? '', + ); + } +} + class LoginResult { final ProfileData profile; final String? updatedToken; @@ -212,6 +241,24 @@ class AccountModule { ); } + Future> getSessions() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.sessionsInfo, {}); + _checkPacketError(packet, 'getSessions'); + final data = packet.payload; + if (data is! Map || data['sessions'] is! List) return []; + final sessions = data['sessions'] as List; + return sessions + .map((s) => SessionInfo.fromMap(s as Map)) + .toList(); + } + + Future terminateOtherSessions() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.sessionsClose, {}); + _checkPacketError(packet, 'terminateOtherSessions'); + } + Future switchAccount(int accountId) async { final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart new file mode 100644 index 0000000..c31090b --- /dev/null +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -0,0 +1,600 @@ +import 'dart:io'; +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show accountModule; +import '../../../backend/modules/account.dart' show SessionInfo; +import '../../widgets/custom_notification.dart'; + +class DevicesScreen extends StatefulWidget { + const DevicesScreen({super.key}); + + @override + State createState() => _DevicesScreenState(); +} + +class _DevicesScreenState extends State + with SingleTickerProviderStateMixin { + bool _isLoading = true; + List _sessions = []; + final Map> _ipDetails = {}; + final Set _loadingIps = {}; + final Set _expandedSessions = {}; + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadSessions(); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + Future _loadSessions() async { + try { + final sessions = await accountModule.getSessions(); + if (mounted) { + setState(() { + _sessions = sessions; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка загрузки: $e'); + setState(() => _isLoading = false); + } + } + } + + Future _terminateOthers() async { + try { + await accountModule.terminateOtherSessions(); + if (mounted) { + showCustomNotification(context, 'Все сессии завершены'); + _loadSessions(); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка: $e'); + } + } + } + + Future _lookupIp(int id, String location) async { + if (_ipDetails.containsKey(id)) { + setState(() => _expandedSessions.add(id)); + return; + } + + final reg = RegExp(r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'); + final match = reg.firstMatch(location); + if (match == null) return; + final ip = match.group(0)!; + + if (mounted) { + setState(() => _loadingIps.add(id)); + } + + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 5); + final request = await client.getUrl( + Uri.parse( + 'http://ip-api.com/json/$ip?fields=status,message,country,city,isp,as,mobile,proxy,timezone', + ), + ); + final response = await request.close(); + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final data = jsonDecode(body); + if (mounted) { + setState(() { + _ipDetails[id] = data; + _expandedSessions.add(id); + _loadingIps.remove(id); + }); + } + } + } catch (e) { + if (mounted) { + setState(() => _loadingIps.remove(id)); + showCustomNotification(context, 'Ошибка IP: $e'); + } + } + } + + String _formatTime(int timestamp) { + if (timestamp == 0) return ''; + final now = DateTime.now(); + final date = DateTime.fromMillisecondsSinceEpoch(timestamp); + + if (now.year == date.year && + now.month == date.month && + now.day == date.day) { + return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } + + final months = [ + 'янв.', + 'февр.', + 'мар.', + 'апр.', + 'мая', + 'июня', + 'июля', + 'авг.', + 'сент.', + 'окт.', + 'нояб.', + 'дек.', + ]; + + if (now.year == date.year) { + return '${date.day} ${months[date.month - 1]}'; + } + + return '${date.day}.${date.month.toString().padLeft(2, '0')}.${date.year}'; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + scrolledUnderElevation: 0, + leading: IconButton( + icon: const Icon(Symbols.chevron_left, size: 28), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Устройства', + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + centerTitle: true, + ), + body: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + children: [ + const SizedBox(height: 16), + _buildPromoCard(context, cs), + const SizedBox(height: 12), + _buildDevicesList(context, cs), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildPromoCard(BuildContext context, ColorScheme cs) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(24), + ), + child: Center( + child: Column( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: const Color(0xFF15151D), + shape: BoxShape.circle, + border: Border.all( + color: cs.onSurface.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Icon(Symbols.devices, color: cs.onSurface, size: 28), + ), + const SizedBox(height: 16), + Text( + 'Устройства в KOMET', + style: GoogleFonts.outfit( + fontSize: 18, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + 'Кто имеет доступ к вашему аккаунту?', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + height: 1.3, + ), + ), + ], + ), + ), + ); + } + + Widget _buildDevicesList(BuildContext context, ColorScheme cs) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(24), + ), + child: Column( + children: [ + if (_isLoading) + ...List.generate(5, (index) => _buildShimmerItem(cs)) + else + ..._sessions.map( + (session) => _buildDeviceItem( + context, + cs, + id: session.id ?? 0, + title: session.client + (session.current ? ' (текущая)' : ''), + platform: session.info, + location: session.location, + status: session.current ? 'В сети' : null, + time: session.current ? null : _formatTime(session.time), + isOnline: session.current, + ), + ), + if (!_isLoading) ...[ + const SizedBox(height: 12), + Divider( + height: 1, + color: cs.onSurface.withValues(alpha: 0.05), + indent: 20, + endIndent: 20, + ), + InkWell( + onTap: _terminateOthers, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(24), + ), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 20, + ), + child: Text( + 'Завершить все сессии, кроме текущей', + style: TextStyle( + color: cs.error.withValues(alpha: 0.8), + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildShimmerItem(ColorScheme cs) { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.2 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 140, + height: 16, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(height: 8), + Container( + width: 100, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 4), + Container( + width: 180, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ), + Container( + width: 40, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildDeviceItem( + BuildContext context, + ColorScheme cs, { + required int id, + required String title, + required String platform, + required String location, + String? status, + String? time, + bool isOnline = false, + }) { + final details = _ipDetails[id]; + final isLoading = _loadingIps.contains(id); + final isExpanded = _expandedSessions.contains(id); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.outfit( + fontSize: 16, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + platform, + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + Text( + location, + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (status != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: isOnline ? Colors.greenAccent : cs.outline, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + status, + style: TextStyle( + color: isOnline + ? Colors.greenAccent + : cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + else if (time != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + time, + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.5), + fontSize: 13, + ), + ), + ), + const SizedBox(height: 8), + if (!isExpanded) + InkWell( + onTap: isLoading ? null : () => _lookupIp(id, location), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(4), + child: isLoading + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + ) + : Icon( + Symbols.add_circle, + size: 20, + color: cs.onSurfaceVariant.withValues( + alpha: 0.4, + ), + ), + ), + ), + ], + ), + ], + ), + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutQuart, + child: isExpanded && details != null + ? Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.onSurface.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow( + cs, + Symbols.location_city, + '${details['city'] ?? 'Unknown'}, ${details['country'] ?? ''}', + ), + _buildDetailRow( + cs, + Symbols.dns, + details['isp'] ?? 'Unknown', + ), + _buildDetailRow( + cs, + Symbols.public, + details['as'] ?? 'Unknown', + ), + if (details['mobile'] == true) + _buildDetailRow( + cs, + Symbols.stay_current_portrait, + 'Мобильная сеть', + color: Colors.blueAccent, + ), + if (details['proxy'] == true) + _buildDetailRow( + cs, + Symbols.vpn_lock, + 'Обнаружен прокси/VPN', + color: Colors.orangeAccent, + ), + _buildDetailRow( + cs, + Symbols.schedule, + details['timezone'] ?? 'Unknown', + ), + ], + ), + Positioned( + right: 0, + bottom: 0, + child: InkWell( + onTap: () => + setState(() => _expandedSessions.remove(id)), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(4), + child: Icon( + Symbols.do_not_disturb_on, + size: 20, + color: cs.onSurfaceVariant.withValues( + alpha: 0.4, + ), + ), + ), + ), + ), + ], + ), + ) + : const SizedBox(width: double.infinity, height: 0), + ), + ], + ), + ); + } + + Widget _buildDetailRow( + ColorScheme cs, + IconData icon, + String text, { + Color? color, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon( + icon, + size: 14, + color: color ?? cs.onSurfaceVariant.withValues(alpha: 0.6), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 12, + color: color ?? cs.onSurfaceVariant.withValues(alpha: 0.8), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 05ca27c..1f6a0ba 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; +import 'devices_screen.dart'; class SettingsTab extends StatefulWidget { const SettingsTab({super.key}); @@ -69,13 +70,27 @@ class _SettingsTabState extends State { child: _buildSection( context, cs, - items: const [ - _SettingsItem( + items: [ + const _SettingsItem( icon: Symbols.notifications_active, label: 'Уведомления и звук', ), - _SettingsItem(icon: Symbols.lock, label: 'Безопасность'), - _SettingsItem(icon: Symbols.devices, label: 'Устройства'), + const _SettingsItem( + icon: Symbols.lock, + label: 'Безопасность', + ), + _SettingsItem( + icon: Symbols.devices, + label: 'Устройства', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DevicesScreen(), + ), + ); + }, + ), ], ), ), @@ -231,7 +246,7 @@ class _SettingsTabState extends State { Material( color: Colors.transparent, child: InkWell( - onTap: () {}, + onTap: item.onTap ?? () {}, borderRadius: isLast ? const BorderRadius.vertical(bottom: Radius.circular(20)) : null, @@ -284,8 +299,9 @@ class _SettingsTabState extends State { class _SettingsItem { final IconData icon; final String label; + final VoidCallback? onTap; - const _SettingsItem({required this.icon, required this.label}); + const _SettingsItem({required this.icon, required this.label, this.onTap}); } class _PhoneSpoiler extends StatefulWidget { From 3f99bda6d2d8d6cec918d5c68fd2c8521aa7a870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sat, 4 Apr 2026 13:31:13 +0700 Subject: [PATCH 034/323] =?UTF-8?q?=D0=BD=D0=B0=D1=85=D1=83=D0=B5=D0=B2?= =?UTF-8?q?=D1=91=D1=80=D1=82=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 12 ++ lib/backend/modules/account.dart | 73 ++++--- lib/backend/modules/chats.dart | 2 - lib/core/protocol/packet.dart | 4 + lib/core/transport/dispatcher.dart | 10 +- .../screens/chats/chat_list_screen.dart | 195 +++++++++++------- .../screens/profile/devices_screen.dart | 2 +- lib/main.dart | 34 ++- pubspec.lock | 16 +- 9 files changed, 236 insertions(+), 112 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index aedf00d..72f987c 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -30,6 +30,8 @@ class Api { SessionState _sessionState = SessionState.disconnected; final _stateController = StreamController.broadcast(); + final _sessionExpiredController = + StreamController.broadcast(); Map? _userAgent; Map? get userAgent => _userAgent; @@ -40,6 +42,8 @@ class Api { _registrationCountries ?? allCountries; Stream get stateStream => _stateController.stream; + Stream get sessionExpiredStream => + _sessionExpiredController.stream; SessionState get state => _sessionState; StreamSubscription? _dataSubscription; @@ -195,6 +199,7 @@ class Api { _dispatcher.dispose(); _connection.dispose(); _stateController.close(); + _sessionExpiredController.close(); } // Внутрянка @@ -208,6 +213,13 @@ class Api { Future _onDataReceived(Uint8List data) async { await for (final packet in _receiver.feed(data)) { + if (packet.isError && + packet.payload is Map && + packet.payload['message'] == 'FAIL_LOGIN_TOKEN') { + _sessionExpiredController.add( + SessionExpiredException(messageFromErrorPayload(packet.payload)), + ); + } _dispatcher.dispatch(packet); } } diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 82a6505..ec64490 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -17,6 +18,8 @@ enum AuthRequestType { final String value; } +enum LoginStatus { idle, loading, success, error } + class RequestCodeResult { final String token; @@ -39,10 +42,8 @@ class VerifyCodeResult { return c is Map ? c.cast() : null; } - /// trackId из passwordChallenge — передаётся в [AccountModule.checkPassword]. String? get challengeTrackId => passwordChallenge?['trackId'] as String?; - /// Подсказка к паролю из passwordChallenge. String? get challengeHint => passwordChallenge?['hint'] as String?; int? get accountId { @@ -68,8 +69,6 @@ class TwoFactorResult { const TwoFactorResult({required this.loginToken}); } -/// При отсутствии [LoginSyncParams] в [AccountModule.login] сервер вернёт -/// полный снимок данных (cold start), иначе только дельту (warm start). class LoginSyncParams { final int chatsSync; final int contactsSync; @@ -131,7 +130,9 @@ class SessionInfo { factory SessionInfo.fromMap(Map map) { return SessionInfo( - id: map['id'], + id: map['id'] is int + ? map['id'] + : (int.tryParse(map['id']?.toString() ?? '')), client: map['client'] ?? '', location: map['location'] ?? '', current: map['current'] ?? false, @@ -139,6 +140,23 @@ class SessionInfo { info: map['info'] ?? '', ); } + + int get uniqueId => Object.hash(id, client, time, info); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SessionInfo && + runtimeType == other.runtimeType && + id == other.id && + client == other.client && + location == other.location && + current == other.current && + time == other.time && + info == other.info; + + @override + int get hashCode => Object.hash(id, client, location, current, time, info); } class LoginResult { @@ -157,9 +175,12 @@ class LoginResult { class AccountModule { final Api _api; + final _loginStatusController = StreamController.broadcast(); AccountModule(this._api); + Stream get loginStatusStream => _loginStatusController.stream; + Future requestCode( String phone, { String language = 'ru', @@ -200,7 +221,6 @@ class AccountModule { if (sessionToken != null && accountId != null) { await TokenStorage.saveToken(sessionToken, accountId); await TokenStorage.setActiveAccount(accountId); - logger.i('Токен аккаунта $accountId сохранён, установлен активным'); } return result; @@ -226,19 +246,27 @@ class AccountModule { final requestPayload = _buildLoginPayload(authToken, syncParams); - final packet = await _api.sendRequest(Opcode.login, requestPayload); + _loginStatusController.add(LoginStatus.loading); + try { + final packet = await _api.sendRequest(Opcode.login, requestPayload); - _checkPacketError(packet, 'login'); + _checkPacketError(packet, 'login'); - final data = packet.payload; - if (data is! Map) { - throw Exception('login: неожиданный тип payload: ${data.runtimeType}'); + final data = packet.payload; + if (data is! Map) { + throw Exception('login: неожиданный тип payload: ${data.runtimeType}'); + } + + final result = await _processLoginResponse( + data.cast(), + resolvedAccountId, + ); + _loginStatusController.add(LoginStatus.success); + return result; + } catch (e) { + _loginStatusController.add(LoginStatus.error); + rethrow; } - - return _processLoginResponse( - data.cast(), - resolvedAccountId, - ); } Future> getSessions() async { @@ -278,13 +306,6 @@ class AccountModule { logger.i('Аккаунт $accountId удалён локально'); } - /// Проверяет 2FA-пароль (opcode 115). - /// - /// [trackId] — из [VerifyCodeResult.challengeTrackId]. - /// [accountId] — из [VerifyCodeResult.accountId]. - /// - /// При неверном пароле бросает [Exception]. - /// При успехе сохраняет токен и устанавливает аккаунт активным. Future checkPassword({ required String password, required String trackId, @@ -487,7 +508,11 @@ class AccountModule { void _checkPacketError(Packet packet, String method) { if (packet.isError) { - throw PacketError(messageFromErrorPayload(packet.payload)); + final payload = packet.payload; + if (payload is Map && payload['message'] == 'FAIL_LOGIN_TOKEN') { + throw SessionExpiredException(messageFromErrorPayload(payload)); + } + throw PacketError(messageFromErrorPayload(payload)); } } } diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 33413d3..f8c81d6 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -137,8 +137,6 @@ class ChatsModule { static Future clearCache(int accountId) => AppDatabase.clearChatsCache(accountId); - // internal - static Map> _buildContactsMap(dynamic contacts) { if (contacts is! List) return {}; final result = >{}; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 6970165..8bd0bc4 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -59,6 +59,10 @@ class PacketError implements Exception { String toString() => message; } +class SessionExpiredException extends PacketError { + const SessionExpiredException(super.message); +} + String messageFromErrorPayload(dynamic payload) { if (payload is Map) { for (final key in ['localizedMessage', 'message', 'title']) { diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index bb22136..a7ff78e 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -68,9 +68,13 @@ class PacketDispatcher { } if (packet.isError) { - completer.completeError( - PacketError(messageFromErrorPayload(packet.payload)), - ); + final message = messageFromErrorPayload(packet.payload); + if (packet.payload is Map && + packet.payload['message'] == 'FAIL_LOGIN_TOKEN') { + completer.completeError(SessionExpiredException(message)); + } else { + completer.completeError(PacketError(message)); + } } else { completer.complete(packet); } diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 78d1aaf..1bf2d97 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -15,6 +15,31 @@ import '../../../backend/modules/chats.dart'; import '../../../core/storage/app_database.dart'; import '../../../main.dart' show api; +class _StoriesScrollPhysics extends BouncingScrollPhysics { + final bool Function() blockPositive; + + const _StoriesScrollPhysics({ + required this.blockPositive, + ScrollPhysics? parent, + }) : super(parent: parent); + + @override + _StoriesScrollPhysics applyTo(ScrollPhysics? ancestor) { + return _StoriesScrollPhysics( + blockPositive: blockPositive, + parent: buildParent(ancestor), + ); + } + + @override + double applyBoundaryConditions(ScrollMetrics position, double value) { + if (blockPositive() && value > 0.0) { + return value - max(0.0, position.pixels); + } + return super.applyBoundaryConditions(position, value); + } +} + class ChatListScreen extends StatefulWidget { const ChatListScreen({super.key}); @@ -62,13 +87,7 @@ class _ChatListScreenState extends State _selectedChats.add(chatId); } - if (_isSelectionMode) { - if (_scrollController.hasClients && _scrollController.offset < 132) { - _shouldCollapseSearch = true; - } - } else { - _shouldCollapseSearch = false; - } + _shouldCollapseSearch = _isSelectionMode; }); } @@ -80,6 +99,20 @@ class _ChatListScreenState extends State } bool _isInitialLoading = true; + DateTime _storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0); + + bool _shouldBlockPositiveScroll() { + if (_pullRatio > 0 || + _storiesDockedOpen || + _storiesRevealController.isAnimating) { + return true; + } + if (DateTime.now().isBefore(_storiesLockdownUntil)) { + return true; + } + return false; + } + late AnimationController _shimmerController; @override @@ -89,13 +122,14 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); - _navPageAnimController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), - value: 1.0, - )..addListener(() { - if (mounted) setState(() {}); - }); + _navPageAnimController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + value: 1.0, + )..addListener(() { + if (mounted) setState(() {}); + }); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), @@ -272,6 +306,9 @@ class _ChatListScreenState extends State return; } if (_storiesAnimClosing && _storiesRevealController.isAnimating) return; + _storiesLockdownUntil = DateTime.now().add( + const Duration(milliseconds: 800), + ); _storiesRevealController.stop(); _storiesAnimClosing = true; final from = _pullRatio.clamp(0.0, 1.0); @@ -293,8 +330,20 @@ class _ChatListScreenState extends State bool _onStoriesScrollNotification(ScrollNotification n) { if (_currentNavIndex != 0) return false; - if (n is! ScrollUpdateNotification) return false; if (!_scrollController.hasClients) return false; + + if (n is OverscrollNotification && n.overscroll > 0) { + if ((_storiesDockedOpen || + _storiesRevealController.isAnimating || + _pullRatio > 0) && + !_storiesAnimClosing && + DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + _startStoriesAutoClose(); + } + return false; + } + + if (n is! ScrollUpdateNotification) return false; if (!_storiesDockedOpen || _storiesRevealController.isAnimating) { return false; } @@ -372,13 +421,14 @@ class _ChatListScreenState extends State required double Function(int index) bubbleLeftForIndex, }) { if (_navDragging) { - final left = (_navDragBaseLeft + _navDragDx) - .clamp(bubbleLeftForIndex(0), bubbleLeftForIndex(3)); + final left = (_navDragBaseLeft + _navDragDx).clamp( + bubbleLeftForIndex(0), + bubbleLeftForIndex(3), + ); return ((left - 4) / inactiveWidth).clamp(0.0, 3.0); } if (_navPageAnimController.isAnimating) { - final t = - Curves.easeOutCubic.transform(_navPageAnimController.value); + final t = Curves.easeOutCubic.transform(_navPageAnimController.value); return ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; } return _currentNavIndex.toDouble(); @@ -390,8 +440,7 @@ class _ChatListScreenState extends State } double fromT; if (_navPageAnimController.isAnimating) { - final t = - Curves.easeOutCubic.transform(_navPageAnimController.value); + final t = Curves.easeOutCubic.transform(_navPageAnimController.value); fromT = ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; } else { fromT = _currentNavIndex.toDouble(); @@ -428,7 +477,7 @@ class _ChatListScreenState extends State curve: Curves.easeOutCubic, alignment: Alignment.topCenter, child: _shouldCollapseSearch - ? const SizedBox(width: double.infinity, height: 0) + ? const SizedBox(width: double.infinity, height: 52) : Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -667,8 +716,17 @@ class _ChatListScreenState extends State Widget _buildChatsTabBody() { return Listener( + onPointerDown: (_) { + _storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0); + }, onPointerSignal: (pointerSignal) { if (pointerSignal is PointerScrollEvent) { + if (_shouldBlockPositiveScroll() && + pointerSignal.scrollDelta.dy > 0) { + _storiesLockdownUntil = DateTime.now().add( + const Duration(milliseconds: 300), + ); + } if (_scrollController.hasClients && _scrollController.offset <= 0) { if (pointerSignal.scrollDelta.dy < 0) { _startStoriesAutoReveal(max(_pullRatio, 0.18)); @@ -687,37 +745,32 @@ class _ChatListScreenState extends State Expanded( child: CustomScrollView( controller: _scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), + physics: _StoriesScrollPhysics( + blockPositive: _shouldBlockPositiveScroll, + parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, - childCount: - _isInitialLoading ? 10 : _chats.length, - ), - ), - const SliverPadding( - padding: EdgeInsets.only(bottom: 100), + delegate: SliverChildBuilderDelegate((context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, childCount: _isInitialLoading ? 10 : _chats.length), ), + const SliverPadding(padding: EdgeInsets.only(bottom: 100)), ], ), ), @@ -747,12 +800,10 @@ class _ChatListScreenState extends State final maxBubbleLeft = bubbleLeftForIndex(3); final bubbleLeft = _navDragging - ? (_navDragBaseLeft + _navDragDx) - .clamp(minBubbleLeft, maxBubbleLeft) + ? (_navDragBaseLeft + _navDragDx).clamp(minBubbleLeft, maxBubbleLeft) : leftOffset; - final navRowT = - ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); + final navRowT = ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); double navInterpolatedWidth(int tabIndex, double rowT) { final rt = rowT.clamp(0.0, 3.0); @@ -820,8 +871,10 @@ class _ChatListScreenState extends State }, onHorizontalDragEnd: (_) { if (!_navDragging) return; - final left = (_navDragBaseLeft + _navDragDx) - .clamp(minBubbleLeft, maxBubbleLeft); + final left = (_navDragBaseLeft + _navDragDx).clamp( + minBubbleLeft, + maxBubbleLeft, + ); final next = indexForBubbleLeft(left); setState(() { _currentNavIndex = next; @@ -886,8 +939,8 @@ class _ChatListScreenState extends State width: _navDragging ? navInterpolatedWidth(index, navRowT) : (isSelected - ? (activeWidth - 0.5) - : (inactiveWidth - 0.5)), + ? (activeWidth - 0.5) + : (inactiveWidth - 0.5)), child: ClipRRect( borderRadius: BorderRadius.circular(26), child: _buildNavItem( @@ -939,7 +992,8 @@ class _ChatListScreenState extends State bubbleLeftForIndex: bubbleLeftForPageT, ); - final showChatsFab = !_isSelectionMode && + final showChatsFab = + !_isSelectionMode && (_navDragging || _navPageAnimController.isAnimating ? pageDisplayT < 1.0 : _currentNavIndex == 0); @@ -950,10 +1004,10 @@ class _ChatListScreenState extends State child: SizedBox( width: pageW, height: pageH, - child: UnconstrainedBox( - constrainedAxis: Axis.vertical, + child: OverflowBox( alignment: Alignment.topLeft, - clipBehavior: Clip.hardEdge, + maxWidth: pageW * 4, + maxHeight: pageH, child: SizedBox( width: pageW * 4, height: pageH, @@ -1013,7 +1067,9 @@ class _ChatListScreenState extends State onTap: _toggleFab, behavior: HitTestBehavior.opaque, child: Container( - color: Colors.black.withValues(alpha: val * 0.2), + color: Colors.black.withValues( + alpha: val * 0.2, + ), ), ), ), @@ -1066,7 +1122,7 @@ class _ChatListScreenState extends State left: 0, right: 0, child: Container( - height: 64, + height: 52, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: cs.surface, @@ -1360,12 +1416,13 @@ class _ChatListScreenState extends State bool instant = false, }) { final cs = Theme.of(context).colorScheme; - final bool isSelected = - selectedOverride ?? (_currentNavIndex == index); - final Duration animDur = - instant ? Duration.zero : const Duration(milliseconds: 350); - final Duration opacityDur = - instant ? Duration.zero : const Duration(milliseconds: 200); + final bool isSelected = selectedOverride ?? (_currentNavIndex == index); + final Duration animDur = instant + ? Duration.zero + : const Duration(milliseconds: 350); + final Duration opacityDur = instant + ? Duration.zero + : const Duration(milliseconds: 200); return GestureDetector( onTap: () => _onNavTabSelected(index), behavior: HitTestBehavior.opaque, diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index c31090b..c400d60 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -252,7 +252,7 @@ class _DevicesScreenState extends State (session) => _buildDeviceItem( context, cs, - id: session.id ?? 0, + id: session.uniqueId, title: session.client + (session.current ? ' (текущая)' : ''), platform: session.info, location: session.location, diff --git a/lib/main.dart b/lib/main.dart index 1e20ee2..a210687 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,8 +8,10 @@ import 'backend/modules/account.dart'; import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; +import 'core/protocol/packet.dart'; import 'frontend/screens/auth/login_screen.dart'; import 'frontend/screens/chats/chat_list_screen.dart'; +import 'frontend/widgets/custom_notification.dart'; final api = Api(); final accountModule = AccountModule(api); @@ -40,6 +42,7 @@ class KometApp extends StatefulWidget { const KometApp({super.key, required this.initialLocale}); final Locale initialLocale; + static final navigatorKey = GlobalKey(); static KometAppState? stateOf(BuildContext context) { return context.findAncestorStateOfType(); @@ -53,11 +56,35 @@ class KometAppState extends State { static const _fallbackSeed = Color(0xFFC1C4FF); late Locale _locale; + bool _isLoggingOut = false; @override void initState() { super.initState(); _locale = widget.initialLocale; + api.sessionExpiredStream.listen((SessionExpiredException e) async { + if (_isLoggingOut) return; + _isLoggingOut = true; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await accountModule.removeAccount(accountId); + } + + final navState = KometApp.navigatorKey.currentState; + if (navState != null) { + final overlayContext = navState.overlay?.context; + if (overlayContext != null) { + showCustomNotification(overlayContext, e.message); + } + + await navState.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (route) => false, + ); + } + _isLoggingOut = false; + }); } Future applyLocale(Locale locale) async { @@ -114,6 +141,7 @@ class KometAppState extends State { colorScheme: darkScheme, textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme), ), + navigatorKey: KometApp.navigatorKey, home: const _StartupScreen(), ); }, @@ -151,11 +179,7 @@ class _StartupScreenState extends State<_StartupScreen> { try { await accountModule.login(accountId: accountId); - } catch (e) { - debugPrint( - 'Background auto-login failed (safe to ignore if offline): $e', - ); - } + } catch (_) {} } void _goToLogin() { diff --git a/pubspec.lock b/pubspec.lock index e8fa11e..ca2281c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -614,10 +614,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: From 8a863068bb44145e9d47df5d9ec183dffe3559ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sat, 4 Apr 2026 18:29:22 +0700 Subject: [PATCH 035/323] =?UTF-8?q?=D0=98=D0=BD=D0=BA=20=D0=BD=D0=B0=D1=85?= =?UTF-8?q?=D1=83=D0=B9=20=D0=B8=D0=B4=D0=B8=20=D0=BB=D1=8E=D0=B1=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 326 ++++++++++-------- 1 file changed, 180 insertions(+), 146 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1bf2d97..1a17ef5 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -200,40 +200,43 @@ class _ChatListScreenState extends State return Opacity( opacity: opacity, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( children: [ Container( - width: 50, - height: 50, + width: 48, + height: 48, decoration: BoxDecoration( color: cs.surfaceContainerHighest, shape: BoxShape.circle, ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 120, - height: 14, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(7), + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 120, + height: 14, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(7), + ), ), - ), - const SizedBox(height: 10), - Container( - width: double.infinity, - height: 10, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(5), + Container( + width: double.infinity, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), ), - ), - ], + ], + ), ), ), ], @@ -1274,134 +1277,165 @@ class _ChatListScreenState extends State child: AnimatedContainer( duration: const Duration(milliseconds: 200), color: isSelected ? cs.primary.withOpacity(0.08) : Colors.transparent, - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 2, - ), - leading: Stack( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - CircleAvatar( - radius: 24, - backgroundColor: cs.surfaceContainerHighest, - backgroundImage: imageUrl.isNotEmpty - ? NetworkImage(imageUrl) - : null, - child: imageUrl.isEmpty - ? Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 20, + Stack( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: cs.surfaceContainerHighest, + backgroundImage: imageUrl.isNotEmpty + ? NetworkImage(imageUrl) + : null, + child: imageUrl.isEmpty + ? Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 20, + ), + ) + : null, + ), + if (isSelected) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + child: Icon( + Symbols.check, + color: cs.onPrimary, + size: 14, + weight: 600, + ), + ), + ) + else if (isOnline) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), ), - ) - : null, - ), - if (isSelected) - Positioned( - right: -2, - bottom: -2, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), - child: Icon( - Symbols.check, - color: cs.onPrimary, - size: 14, - weight: 600, - ), - ), - ) - else if (isOnline) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), - ), - ), - ], - ), - title: Row( - children: [ - Expanded( - child: Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ), - if (isMuted) - Icon( - Symbols.notifications_off, - color: cs.outlineVariant, - size: 14, - weight: 400, - ), - const SizedBox(width: 8), - Text(time, style: TextStyle(color: cs.outline, fontSize: 12)), - ], - ), - subtitle: Padding( - padding: const EdgeInsets.only(top: 4), - child: Row( - children: [ - Expanded( - child: Text( - message, - style: TextStyle( - color: isTyping ? cs.primary : cs.outline, - fontSize: 14, - fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (unreadCount > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: isMuted - ? cs.surfaceContainerHighest - : cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - unreadCount.toString(), - style: TextStyle( - color: isMuted ? cs.outline : cs.onSurface, - fontSize: 11, - fontWeight: FontWeight.w600, ), ), - ) - else if (isRead) - Icon( - Symbols.done_all, - color: cs.primary, - size: 16, - weight: 400, + ], + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isMuted) ...[ + const SizedBox(width: 4), + Icon( + Symbols.notifications_off, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + ], + const SizedBox(width: 8), + Text( + time, + style: TextStyle(color: cs.outline, fontSize: 12), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + message, + style: TextStyle( + color: isTyping ? cs.primary : cs.outline, + fontSize: 14, + fontWeight: isTyping + ? FontWeight.w500 + : FontWeight.w400, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + if (unreadCount > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: isMuted + ? cs.surfaceContainerHighest + : cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + unreadCount.toString(), + style: TextStyle( + color: isMuted ? cs.outline : cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ) + else if (isRead) + Icon( + Symbols.done_all, + color: cs.primary, + size: 16, + weight: 400, + ), + ], + ), + ), + ], ), - ], - ), + ), + ), + ], ), ), ), From fbbd284a7a349199a08cdba309ac4c2e26d7a863 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 4 Apr 2026 16:02:36 +0300 Subject: [PATCH 036/323] feat(theme): system light/dark mode and Material You dynamic colors --- .../screens/chats/chat_list_screen.dart | 10 +-- lib/frontend/screens/chats/chat_screen.dart | 81 ++++++++++--------- .../screens/profile/devices_screen.dart | 2 +- lib/main.dart | 42 +++++++++- 4 files changed, 87 insertions(+), 48 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1a17ef5..439c3e0 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -502,14 +502,17 @@ class _ChatListScreenState extends State child: Stack( children: [ _buildFoldedStory( + cs, 'https://i.pravatar.cc/150?u=dasha', 0, ), _buildFoldedStory( + cs, 'https://i.pravatar.cc/150?u=mastika', 1, ), _buildFoldedStory( + cs, 'https://i.pravatar.cc/150?u=stas', 2, ), @@ -1316,7 +1319,6 @@ class _ChatListScreenState extends State Symbols.check, color: cs.onPrimary, size: 14, - weight: 600, ), ), ) @@ -1471,8 +1473,6 @@ class _ChatListScreenState extends State icon, color: isSelected ? cs.onPrimary : cs.onSurface, size: 20, - weight: 400, - fill: isSelected ? 1.0 : 0.0, ), AnimatedContainer( duration: animDur, @@ -1583,13 +1583,13 @@ class _ChatListScreenState extends State ); } - Widget _buildFoldedStory(String imageUrl, int index) { + Widget _buildFoldedStory(ColorScheme cs, String imageUrl, int index) { return Positioned( left: index * 12.0, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: Colors.black, width: 2), + border: Border.all(color: cs.surface, width: 2), ), child: CircleAvatar( radius: 12, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index ab8c985..e7dfa6d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -104,18 +104,17 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; return Scaffold( - backgroundColor: Colors.black, + backgroundColor: cs.surface, appBar: AppBar( - backgroundColor: const Color(0xFF1B1B1B), + backgroundColor: cs.surfaceContainerHigh, + foregroundColor: cs.onSurface, elevation: 0, surfaceTintColor: Colors.transparent, + iconTheme: IconThemeData(color: cs.onSurface), leading: IconButton( - icon: const Icon( - Symbols.arrow_back, - color: Colors.white, - weight: 400, - ), + icon: const Icon(Symbols.arrow_back, weight: 400), onPressed: () => Navigator.pop(context), ), titleSpacing: 0, @@ -129,10 +128,13 @@ class _ChatScreenState extends State else CircleAvatar( radius: 18, - backgroundColor: Colors.blueGrey, + backgroundColor: cs.primaryContainer, child: Text( widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: const TextStyle(color: Colors.white, fontSize: 12), + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 12, + ), ), ), const SizedBox(width: 12), @@ -142,17 +144,17 @@ class _ChatScreenState extends State children: [ Text( widget.name, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600, fontFamily: 'Outfit', ), ), - const Text( + Text( 'last seen recently', style: TextStyle( - color: Colors.grey, + color: cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w400, ), @@ -164,15 +166,11 @@ class _ChatScreenState extends State ), actions: [ IconButton( - icon: const Icon(Symbols.call, color: Colors.white, weight: 400), + icon: const Icon(Symbols.call, weight: 400), onPressed: () {}, ), IconButton( - icon: const Icon( - Symbols.more_vert, - color: Colors.white, - weight: 400, - ), + icon: const Icon(Symbols.more_vert, weight: 400), onPressed: () {}, ), ], @@ -196,6 +194,8 @@ class _ChatScreenState extends State return AnimatedBuilder( animation: _shimmerController, builder: (context, child) { + final cs = Theme.of(context).colorScheme; + final placeholder = cs.surfaceContainerHighest; final opacity = 0.3 + (0.4 * _shimmerController.value); return ListView.builder( padding: const EdgeInsets.all(16), @@ -217,8 +217,8 @@ class _ChatScreenState extends State Container( width: 36, height: 36, - decoration: const BoxDecoration( - color: Color(0xFF2B2B2B), + decoration: BoxDecoration( + color: placeholder, shape: BoxShape.circle, ), ), @@ -231,7 +231,7 @@ class _ChatScreenState extends State width: width1, height: 10, decoration: BoxDecoration( - color: const Color(0xFF2B2B2B), + color: placeholder, borderRadius: BorderRadius.circular(5), ), ), @@ -240,7 +240,7 @@ class _ChatScreenState extends State width: width2, height: 32, decoration: BoxDecoration( - color: const Color(0xFF2B2B2B), + color: placeholder, borderRadius: BorderRadius.circular(10), ), ), @@ -250,7 +250,7 @@ class _ChatScreenState extends State width: double.infinity, height: 120, decoration: BoxDecoration( - color: const Color(0xFF2B2B2B), + color: placeholder, borderRadius: BorderRadius.circular(12), ), ), @@ -265,7 +265,7 @@ class _ChatScreenState extends State height: 16, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration( - color: const Color(0xFF2B2B2B), + color: placeholder, borderRadius: BorderRadius.circular(8), ), ), @@ -286,6 +286,8 @@ class _ChatScreenState extends State } Widget _buildInputArea(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85); return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), @@ -300,10 +302,13 @@ class _ChatScreenState extends State maxHeight: 180, ), decoration: BoxDecoration( - color: const Color(0xFF1B1B1B).withOpacity(0.9), + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), borderRadius: BorderRadius.circular(28), border: Border.all( - color: Colors.white.withOpacity(0.1), + color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5, ), ), @@ -313,7 +318,7 @@ class _ChatScreenState extends State children: [ Icon( Symbols.face, - color: Colors.white.withOpacity(0.7), + color: mutedIcon, size: 24, weight: 400, ), @@ -321,24 +326,24 @@ class _ChatScreenState extends State Expanded( child: TextField( controller: _messageController, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: cs.onSurface, fontSize: 16, ), maxLines: null, keyboardType: TextInputType.multiline, textAlignVertical: TextAlignVertical.center, - decoration: const InputDecoration( + decoration: InputDecoration( hintText: 'Message', hintStyle: TextStyle( - color: Colors.grey, + color: cs.onSurfaceVariant, fontSize: 16, ), border: InputBorder.none, isDense: true, - contentPadding: EdgeInsets.symmetric( + contentPadding: const EdgeInsets.symmetric( vertical: 14, - ), // Выравниваем по Y + ), ), ), ), @@ -354,7 +359,7 @@ class _ChatScreenState extends State padding: const EdgeInsets.only(left: 12), child: Icon( Symbols.attachment, - color: Colors.white.withOpacity(0.7), + color: mutedIcon, size: 24, weight: 400, ), @@ -370,13 +375,13 @@ class _ChatScreenState extends State width: 54, height: 54, alignment: Alignment.center, - decoration: const BoxDecoration( - color: Color(0xFF2B2B2B), + decoration: BoxDecoration( + color: _hasText ? cs.primary : cs.surfaceContainerHighest, shape: BoxShape.circle, ), child: Icon( _hasText ? Symbols.send : Symbols.mic, - color: Colors.white, + color: _hasText ? cs.onPrimary : cs.onSurface, size: 24, weight: 400, ), diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index c400d60..40ab60d 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -201,7 +201,7 @@ class _DevicesScreenState extends State width: 56, height: 56, decoration: BoxDecoration( - color: const Color(0xFF15151D), + color: cs.surfaceContainerHighest, shape: BoxShape.circle, border: Border.all( color: cs.onSurface.withValues(alpha: 0.1), diff --git a/lib/main.dart b/lib/main.dart index a210687..e95dcfa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -100,7 +100,7 @@ class KometAppState extends State { } } - ColorScheme _adjustScheme(ColorScheme base) { + ColorScheme _adjustDarkScheme(ColorScheme base) { return base.copyWith( surface: Color.alphaBlend( base.primary.withValues(alpha: 0.05), @@ -117,29 +117,63 @@ class KometAppState extends State { ); } + ColorScheme _adjustLightScheme(ColorScheme base) { + return base.copyWith( + surface: Color.alphaBlend( + base.primary.withValues(alpha: 0.06), + const Color(0xFFF5F5FA), + ), + surfaceContainerHigh: Color.alphaBlend( + base.primary.withValues(alpha: 0.08), + const Color(0xFFEAEAF2), + ), + surfaceContainerHighest: Color.alphaBlend( + base.primary.withValues(alpha: 0.11), + const Color(0xFFDEDEE8), + ), + ); + } + @override Widget build(BuildContext context) { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - final baseScheme = + final lightBase = + lightDynamic ?? + ColorScheme.fromSeed( + seedColor: _fallbackSeed, + brightness: Brightness.light, + ); + final darkBase = darkDynamic ?? ColorScheme.fromSeed( seedColor: _fallbackSeed, brightness: Brightness.dark, ); - final darkScheme = _adjustScheme(baseScheme); + final lightScheme = _adjustLightScheme(lightBase); + final darkScheme = _adjustDarkScheme(darkBase); return MaterialApp( title: 'Komet', debugShowCheckedModeBanner: false, locale: _locale, + themeMode: ThemeMode.system, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, theme: ThemeData( + useMaterial3: true, + colorScheme: lightScheme, + textTheme: GoogleFonts.interTextTheme( + ThemeData(brightness: Brightness.light).textTheme, + ), + ), + darkTheme: ThemeData( useMaterial3: true, colorScheme: darkScheme, - textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme), + textTheme: GoogleFonts.interTextTheme( + ThemeData(brightness: Brightness.dark).textTheme, + ), ), navigatorKey: KometApp.navigatorKey, home: const _StartupScreen(), From fc2af183ff3de43261038e0e1d8693e0811df93e Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 4 Apr 2026 16:12:08 +0300 Subject: [PATCH 037/323] ix(chat): dock nav above system bar without extra gap on gestures --- .../screens/chats/chat_list_screen.dart | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 439c3e0..6f8462e 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -776,7 +776,11 @@ class _ChatListScreenState extends State ); }, childCount: _isInitialLoading ? 10 : _chats.length), ), - const SliverPadding(padding: EdgeInsets.only(bottom: 100)), + SliverPadding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewPaddingOf(context).bottom + 100, + ), + ), ], ), ), @@ -786,7 +790,11 @@ class _ChatListScreenState extends State ); } - Widget _buildDockedBottomNav(ColorScheme cs, double navInnerW) { + Widget _buildDockedBottomNav( + ColorScheme cs, + double navInnerW, + double bottomInset, + ) { final totalWeight = 5.2; final unitWidth = navInnerW / totalWeight; final activeWidth = unitWidth * 2.2; @@ -841,7 +849,7 @@ class _ChatListScreenState extends State curve: Curves.easeOutCubic, left: 8, right: 8, - bottom: _isSelectionMode ? -100 : 10.0, + bottom: _isSelectionMode ? -100 : bottomInset + 10.0, child: RepaintBoundary( child: Container( height: 68, @@ -979,6 +987,7 @@ class _ChatListScreenState extends State bottom: false, child: LayoutBuilder( builder: (context, constraints) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; final pageW = constraints.maxWidth; final pageH = constraints.maxHeight; final navInnerW = pageW - 20; @@ -1057,7 +1066,7 @@ class _ChatListScreenState extends State ), ), ), - _buildDockedBottomNav(cs, navInnerW), + _buildDockedBottomNav(cs, navInnerW, bottomInset), ListenableBuilder( listenable: _fabController, builder: (context, child) { @@ -1083,7 +1092,7 @@ class _ChatListScreenState extends State if (_fabController.value > 0) Positioned( right: 20, - bottom: 90 + 74, + bottom: bottomInset + 90 + 74, child: RepaintBoundary( child: Transform.scale( scale: val, @@ -1097,7 +1106,7 @@ class _ChatListScreenState extends State ), Positioned( right: 20, - bottom: 90, + bottom: bottomInset + 90, child: FloatingActionButton( onPressed: _toggleFab, backgroundColor: cs.primaryContainer, From f14914868f99f09f30fae3cf4e4a5cebcabb790e Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 4 Apr 2026 16:39:08 +0300 Subject: [PATCH 038/323] fix(chats): require explicit pull to reveal stories after scrolling from top --- .../screens/chats/chat_list_screen.dart | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 6f8462e..0dabd5c 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -17,9 +17,11 @@ import '../../../main.dart' show api; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; + final bool Function() allowPullOverscrollTop; const _StoriesScrollPhysics({ required this.blockPositive, + required this.allowPullOverscrollTop, ScrollPhysics? parent, }) : super(parent: parent); @@ -27,6 +29,7 @@ class _StoriesScrollPhysics extends BouncingScrollPhysics { _StoriesScrollPhysics applyTo(ScrollPhysics? ancestor) { return _StoriesScrollPhysics( blockPositive: blockPositive, + allowPullOverscrollTop: allowPullOverscrollTop, parent: buildParent(ancestor), ); } @@ -36,6 +39,11 @@ class _StoriesScrollPhysics extends BouncingScrollPhysics { if (blockPositive() && value > 0.0) { return value - max(0.0, position.pixels); } + if (!allowPullOverscrollTop() && + value < position.minScrollExtent && + position.pixels <= position.minScrollExtent) { + return value - position.minScrollExtent; + } return super.applyBoundaryConditions(position, value); } } @@ -69,6 +77,7 @@ class _ChatListScreenState extends State double _closeAnimBegin = 0.0; bool _storiesAnimClosing = false; bool _storiesDockedOpen = false; + bool _storiesOverscrollRevealArmed = true; DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); ProfileData? _profile; @@ -113,6 +122,15 @@ class _ChatListScreenState extends State return false; } + bool _allowStoriesPullOverscrollTop() { + if (_storiesDockedOpen || + _storiesRevealController.isAnimating || + _pullRatio > 0) { + return true; + } + return _storiesOverscrollRevealArmed; + } + late AnimationController _shimmerController; @override @@ -267,6 +285,7 @@ class _ChatListScreenState extends State _pullRatio = 0.0; _storiesDockedOpen = false; _storiesAnimClosing = false; + _storiesOverscrollRevealArmed = true; } else { _pullRatio = 1.0; _storiesDockedOpen = true; @@ -320,6 +339,7 @@ class _ChatListScreenState extends State _pullRatio = 0.0; _storiesDockedOpen = false; _storiesAnimClosing = false; + _storiesOverscrollRevealArmed = true; }); return; } @@ -335,6 +355,15 @@ class _ChatListScreenState extends State if (_currentNavIndex != 0) return false; if (!_scrollController.hasClients) return false; + if (n is ScrollEndNotification) { + if (_scrollController.offset <= 0.5) { + setState(() { + _storiesOverscrollRevealArmed = true; + }); + } + return false; + } + if (n is OverscrollNotification && n.overscroll > 0) { if ((_storiesDockedOpen || _storiesRevealController.isAnimating || @@ -375,6 +404,9 @@ class _ChatListScreenState extends State } if (offset < 0) { + if (!_allowStoriesPullOverscrollTop()) { + return; + } final dragRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); if (_storiesRevealController.isAnimating) { return; @@ -397,9 +429,16 @@ class _ChatListScreenState extends State if (_storiesDockedOpen || _storiesRevealController.isAnimating) { return; } - if (_pullRatio > 0) { + final disarm = offset > 3 && _storiesOverscrollRevealArmed; + final clearPull = _pullRatio > 0; + if (disarm || clearPull) { setState(() { - _pullRatio = 0.0; + if (disarm) { + _storiesOverscrollRevealArmed = false; + } + if (clearPull) { + _pullRatio = 0.0; + } }); } } @@ -735,7 +774,9 @@ class _ChatListScreenState extends State } if (_scrollController.hasClients && _scrollController.offset <= 0) { if (pointerSignal.scrollDelta.dy < 0) { - _startStoriesAutoReveal(max(_pullRatio, 0.18)); + if (_allowStoriesPullOverscrollTop()) { + _startStoriesAutoReveal(max(_pullRatio, 0.18)); + } } else if (pointerSignal.scrollDelta.dy > 0 && _pullRatio > 0) { _startStoriesAutoClose(); } @@ -753,6 +794,7 @@ class _ChatListScreenState extends State controller: _scrollController, physics: _StoriesScrollPhysics( blockPositive: _shouldBlockPositiveScroll, + allowPullOverscrollTop: _allowStoriesPullOverscrollTop, parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ From 9fc325cc579e92bf91583381b90ac6368159c931 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 4 Apr 2026 17:23:54 +0300 Subject: [PATCH 039/323] feat(chats): folder sync, local cache, and swipeable folder pages --- lib/backend/models/chat_folder.dart | 145 +++++ lib/backend/modules/account.dart | 14 + lib/backend/modules/folders.dart | 198 +++++++ .../screens/chats/chat_list_screen.dart | 513 +++++++++++++----- 4 files changed, 736 insertions(+), 134 deletions(-) create mode 100644 lib/backend/models/chat_folder.dart create mode 100644 lib/backend/modules/folders.dart diff --git a/lib/backend/models/chat_folder.dart b/lib/backend/models/chat_folder.dart new file mode 100644 index 0000000..0d6044a --- /dev/null +++ b/lib/backend/models/chat_folder.dart @@ -0,0 +1,145 @@ +class ChatFolder { + final String id; + final String title; + final String? emoji; + final List? include; + final List filters; + final bool hideEmpty; + final List widgets; + final List? favorites; + final Map? filterSubjects; + final List? options; + + ChatFolder({ + required this.id, + required this.title, + this.emoji, + this.include, + required this.filters, + required this.hideEmpty, + required this.widgets, + this.favorites, + this.filterSubjects, + this.options, + }); + + factory ChatFolder.fromJson(Map json) { + return ChatFolder( + id: json['id'].toString(), + title: json['title']?.toString() ?? '', + emoji: json['emoji']?.toString(), + include: (json['include'] as List?) + ?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? 0; + return 0; + }) + .toList(), + filters: + (json['filters'] as List?) + ?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? e; + return e; + }) + .toList() ?? + [], + hideEmpty: json['hideEmpty'] ?? false, + widgets: + (json['widgets'] as List?) + ?.map((w) { + if (w is Map) { + return ChatFolderWidget.fromJson(w); + } + return ChatFolderWidget.fromJson( + Map.from(w as Map), + ); + }) + .toList() ?? + [], + favorites: (json['favorites'] as List?) + ?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? 0; + return 0; + }) + .toList(), + filterSubjects: json['filterSubjects'] is Map + ? json['filterSubjects'] as Map + : (json['filterSubjects'] is Map + ? Map.from( + (json['filterSubjects'] as Map).cast(), + ) + : null), + options: (json['options'] as List?) + ?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? 0; + return 0; + }) + .toList(), + ); + } + + Map toJson() => { + 'id': id, + 'title': title, + if (emoji != null) 'emoji': emoji, + if (include != null) 'include': include, + 'filters': filters, + 'hideEmpty': hideEmpty, + 'widgets': widgets.map((w) => w.toJson()).toList(), + if (favorites != null) 'favorites': favorites, + if (filterSubjects != null) 'filterSubjects': filterSubjects, + if (options != null) 'options': options, + }; +} + +class ChatFolderWidget { + final int id; + final String name; + final String description; + final String? iconUrl; + final String? url; + final String? startParam; + final String? background; + final int? appId; + + ChatFolderWidget({ + required this.id, + required this.name, + required this.description, + this.iconUrl, + this.url, + this.startParam, + this.background, + this.appId, + }); + + factory ChatFolderWidget.fromJson(Map json) { + final rawId = json['id']; + return ChatFolderWidget( + id: rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '') ?? 0, + name: json['name']?.toString() ?? '', + description: json['description']?.toString() ?? '', + iconUrl: json['iconUrl']?.toString(), + url: json['url']?.toString(), + startParam: json['startParam']?.toString(), + background: json['background']?.toString(), + appId: json['appId'] is int + ? json['appId'] as int + : int.tryParse(json['appId']?.toString() ?? ''), + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + if (iconUrl != null) 'iconUrl': iconUrl, + if (url != null) 'url': url, + if (startParam != null) 'startParam': startParam, + if (background != null) 'background': background, + if (appId != null) 'appId': appId, + }; +} diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index ec64490..67aeb2d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -7,6 +7,7 @@ import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import 'chats.dart'; import 'contacts.dart'; +import 'folders.dart'; enum AuthRequestType { startAuth('START_AUTH'), @@ -429,6 +430,19 @@ class AccountModule { await ContactsModule.syncFromLoginPayload(data, profile.id); await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id); + final config = data['config']; + if (config is Map) { + await FoldersModule.applyFromLoginConfig( + profile.id, + config.cast(), + ); + } + try { + await FoldersModule.syncFromServer(_api, profile.id); + } catch (e) { + logger.w('Папки чатов: $e'); + } + return LoginResult( profile: profile, updatedToken: updatedToken, diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart new file mode 100644 index 0000000..7f66fd2 --- /dev/null +++ b/lib/backend/modules/folders.dart @@ -0,0 +1,198 @@ +import 'dart:convert'; + +import '../api.dart'; +import '../models/chat_folder.dart'; +import 'chats.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; + +class FoldersModule { + static const _syncKey = 'chat_folders_snapshot'; + static const _listReadyKey = 'chat_folders_list_ready'; + + static Future markFoldersListReady(int accountId) async { + await AppDatabase.setSyncValue(accountId, _listReadyKey, '1'); + } + + static Future hasReceivedFoldersList(int accountId) async { + final ready = await AppDatabase.getSyncValue(accountId, _listReadyKey); + if (ready == '1') return true; + final snap = await AppDatabase.getSyncValue(accountId, _syncKey); + return snap != null && snap.isNotEmpty; + } + + static bool isAllChatsFolder(ChatFolder f) { + if (f.id == 'all.chat.folder') return true; + final t = f.title.trim().toLowerCase(); + return t == 'все' || + t == 'все чаты' || + t == 'all' || + t == 'all chats'; + } + + static String? preferredInitialFolderId(List folders) { + if (folders.isEmpty) return null; + for (final f in folders) { + if (isAllChatsFolder(f)) return f.id; + } + return folders.first.id; + } + + static void sortFoldersInPlace( + List folders, + List? foldersOrder, + ) { + if (foldersOrder == null || foldersOrder.isEmpty) return; + final orderedIds = foldersOrder.map((id) => id.toString()).toList(); + folders.sort((a, b) { + final aIndex = orderedIds.indexOf(a.id); + final bIndex = orderedIds.indexOf(b.id); + if (aIndex == -1 && bIndex == -1) return 0; + if (aIndex == -1) return 1; + if (bIndex == -1) return -1; + return aIndex.compareTo(bIndex); + }); + } + + static bool chatMatchesFolder(CachedChat chat, ChatFolder folder) { + if (folder.include != null && folder.include!.isNotEmpty) { + return folder.include!.contains(chat.id); + } + if (folder.filters.isEmpty) return false; + + final hasContact = folder.filters.any( + (f) => f == 9 || f == '9' || f == 'CONTACT', + ); + final hasNotContact = folder.filters.any( + (f) => f == 8 || f == '8' || f == 'NOT_CONTACT', + ); + + if (hasContact && hasNotContact) { + if (chat.type != 'DIALOG') return false; + return true; + } + + for (final filter in folder.filters) { + if (filter == 0 || filter == '0' || filter == 'UNREAD') { + if (chat.unreadCount > 0) return true; + } else if (filter == 9 || filter == '9' || filter == 'CONTACT') { + if (chat.type == 'DIALOG') return true; + } else if (filter == 8 || filter == '8' || filter == 'NOT_CONTACT') { + if (chat.type == 'CHAT' || chat.type == 'CHANNEL') return true; + } + } + return false; + } + + static Future> loadFolders(int accountId) async { + final raw = await AppDatabase.getSyncValue(accountId, _syncKey); + if (raw == null || raw.isEmpty) return []; + try { + final map = jsonDecode(raw) as Map; + final folders = (map['folders'] as List?) + ?.map((e) { + final m = e is Map + ? e + : Map.from(e as Map); + return ChatFolder.fromJson(m); + }) + .toList() ?? + []; + final order = map['foldersOrder'] as List?; + sortFoldersInPlace(folders, order); + return folders; + } catch (_) { + return []; + } + } + + static Future _persist( + int accountId, + List folders, + List? order, + ) async { + await AppDatabase.setSyncValue( + accountId, + _syncKey, + jsonEncode({ + 'folders': folders.map((f) => f.toJson()).toList(), + 'foldersOrder': order, + }), + ); + } + + static Future applyPayload( + int accountId, + Map payload, + ) async { + final foldersJson = payload['folders'] as List?; + final order = payload['foldersOrder'] as List?; + if (foldersJson == null && order == null) return; + + List folders; + if (foldersJson != null) { + folders = foldersJson + .map((json) { + try { + final m = json is Map + ? json + : Map.from(json as Map); + return ChatFolder.fromJson(m); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + } else { + folders = await loadFolders(accountId); + } + sortFoldersInPlace(folders, order); + await _persist(accountId, folders, order); + } + + static Future applyFromLoginConfig( + int accountId, + Map config, + ) async { + final chatFolders = config['chatFolders']; + if (chatFolders is! Map) return; + final foldersJson = chatFolders['FOLDERS'] as List?; + if (foldersJson == null) return; + final order = chatFolders['foldersOrder'] as List?; + final folders = foldersJson + .map((json) { + try { + final m = json is Map + ? json + : Map.from(json as Map); + return ChatFolder.fromJson(m); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + sortFoldersInPlace(folders, order); + await _persist(accountId, folders, order); + await markFoldersListReady(accountId); + } + + static Future syncFromServer(Api api, int accountId) async { + try { + final packet = await api.sendRequest(Opcode.foldersGet, { + 'folderSync': 0, + }); + if (packet.isError) { + throw PacketError(messageFromErrorPayload(packet.payload)); + } + final data = packet.payload; + if (data is Map) { + await applyPayload(accountId, data.cast()); + } + } finally { + await markFoldersListReady(accountId); + } + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 0dabd5c..321f739 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -11,9 +11,12 @@ import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; import '../profile/settings_tab.dart'; import '../../../backend/api.dart'; +import '../../../backend/models/chat_folder.dart'; +import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; +import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; -import '../../../main.dart' show api; +import '../../../main.dart' show accountModule, api; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -57,7 +60,8 @@ class ChatListScreen extends StatefulWidget { class _ChatListScreenState extends State with TickerProviderStateMixin { - String _selectedCategory = 'Все чаты'; + String? _selectedFolderId; + List _folders = []; int _currentNavIndex = 0; bool _navDragging = false; double _navDragDx = 0; @@ -69,7 +73,9 @@ class _ChatListScreenState extends State bool _showCacheWarning = false; late AnimationController _fabController; final Set _selectedChats = {}; - final ScrollController _scrollController = ScrollController(); + late PageController _folderPageController; + final List _folderChatScrollControllers = []; + final List _folderChatScrollListenerFns = []; double _pullRatio = 0.0; static const double _kStoriesPullTriggerPx = 16.0; late AnimationController _storiesRevealController; @@ -84,6 +90,8 @@ class _ChatListScreenState extends State List _chats = []; SessionState _sessionState = SessionState.disconnected; StreamSubscription? _stateSub; + StreamSubscription? _loginSub; + bool? _foldersListKnown; bool _shouldCollapseSearch = false; bool get _isSelectionMode => _selectedChats.isNotEmpty; @@ -161,7 +169,8 @@ class _ChatListScreenState extends State ..addListener(_onStoriesRevealTick) ..addStatusListener(_onStoriesRevealStatus); - _scrollController.addListener(_onScroll); + _folderPageController = PageController(); + _syncFolderChatScrollControllers(); _sessionState = api.state; _stateSub = api.stateStream.listen((state) { @@ -175,32 +184,220 @@ class _ChatListScreenState extends State _showCacheWarning = false; } }); + if (state == SessionState.online) { + _reloadChatsAndFolders(); + } } }); - _loadProfile(); + _loginSub = accountModule.loginStatusStream.listen((status) { + if (status == LoginStatus.success) { + _reloadChatsAndFolders(); + } + }); + _reloadChatsAndFolders(); } - Future _loadProfile() async { + Future _reloadChatsAndFolders() async { final p = await AppDatabase.loadActiveProfile(); if (p != null) { final chats = await ChatsModule.getChats(p.id); + final folders = await FoldersModule.loadFolders(p.id); + final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); + final pageCount = folders.isEmpty ? 1 : folders.length; + _syncFolderChatScrollControllersForCount(pageCount); if (mounted) { setState(() { _profile = p; _chats = chats; + _folders = folders; + _foldersListKnown = foldersKnown; + if (_selectedFolderId != null && + !_folders.any((f) => f.id == _selectedFolderId)) { + _selectedFolderId = null; + } + if (_folders.isNotEmpty) { + final preferred = FoldersModule.preferredInitialFolderId(_folders); + if (_selectedFolderId == null || + !_folders.any((f) => f.id == _selectedFolderId)) { + _selectedFolderId = preferred; + } + } else { + _selectedFolderId = null; + } _isInitialLoading = false; }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _jumpFolderPageToSelection(); + }); } } else { + _syncFolderChatScrollControllersForCount(1); if (mounted) { setState(() { + _folders = []; + _selectedFolderId = null; + _foldersListKnown = null; _isInitialLoading = false; }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _jumpFolderPageToSelection(); + }); } } } + bool get _showFoldersShimmer { + if (_profile == null) return false; + if (_foldersListKnown != false) return false; + return _sessionState != SessionState.disconnected; + } + + int get _folderPageCount => _folders.isEmpty ? 1 : _folders.length; + + int get _selectedFolderIndex { + if (_folders.isEmpty) return 0; + final i = _folders.indexWhere((f) => f.id == _selectedFolderId); + if (i >= 0) return i; + return 0; + } + + int _folderIndexForId(String? id) { + if (_folders.isEmpty) return 0; + if (id == null) return 0; + final i = _folders.indexWhere((f) => f.id == id); + if (i >= 0) return i; + final pref = FoldersModule.preferredInitialFolderId(_folders); + if (pref != null) { + final j = _folders.indexWhere((f) => f.id == pref); + if (j >= 0) return j; + } + return 0; + } + + List _chatsForPageIndex(int pageIndex) { + if (_folders.isEmpty) return _chats; + if (pageIndex < 0 || pageIndex >= _folders.length) return _chats; + final folder = _folders[pageIndex]; + if (FoldersModule.isAllChatsFolder(folder)) return _chats; + return _chats + .where((c) => FoldersModule.chatMatchesFolder(c, folder)) + .toList(); + } + + void _syncFolderChatScrollControllers() { + _syncFolderChatScrollControllersForCount(_folderPageCount); + } + + void _syncFolderChatScrollControllersForCount(int n) { + while (_folderChatScrollControllers.length < n) { + final i = _folderChatScrollControllers.length; + void fn() => _onFolderChatScrollAt(i); + final c = ScrollController(); + c.addListener(fn); + _folderChatScrollControllers.add(c); + _folderChatScrollListenerFns.add(fn); + } + while (_folderChatScrollControllers.length > n) { + final c = _folderChatScrollControllers.removeLast(); + final fn = _folderChatScrollListenerFns.removeLast(); + c.removeListener(fn); + c.dispose(); + } + } + + bool _isChatScrollControllerActive(int index) { + if (_folderPageCount <= 1) return index == 0; + if (!_folderPageController.hasClients) { + return index == _selectedFolderIndex; + } + final p = _folderPageController.page; + if (p == null) return index == _selectedFolderIndex; + final r = p.round().clamp(0, _folderPageCount - 1); + return r == index; + } + + void _onFolderChatScrollAt(int index) { + if (!_isChatScrollControllerActive(index)) return; + if (index < 0 || index >= _folderChatScrollControllers.length) return; + final c = _folderChatScrollControllers[index]; + _applyChatScrollOffset(c); + } + + void _applyChatScrollOffset(ScrollController c) { + if (!c.hasClients) return; + final double offset = c.offset; + if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) { + setState(() { + _shouldCollapseSearch = true; + }); + } + + if (offset < 0) { + if (!_allowStoriesPullOverscrollTop()) { + return; + } + final dragRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); + if (_storiesRevealController.isAnimating) { + return; + } + if (!_storiesDockedOpen && offset.abs() >= _kStoriesPullTriggerPx) { + _startStoriesAutoReveal(dragRatio); + } else if (!_storiesDockedOpen) { + if (dragRatio != _pullRatio) { + setState(() { + _pullRatio = dragRatio; + }); + } + } + } else { + if (_storiesDockedOpen && + offset > 12 && + DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + _startStoriesAutoClose(); + } + if (_storiesDockedOpen || _storiesRevealController.isAnimating) { + return; + } + final disarm = offset > 3 && _storiesOverscrollRevealArmed; + final clearPull = _pullRatio > 0; + if (disarm || clearPull) { + setState(() { + if (disarm) { + _storiesOverscrollRevealArmed = false; + } + if (clearPull) { + _pullRatio = 0.0; + } + }); + } + } + } + + ScrollController? _activeChatScrollController() { + if (_folderChatScrollControllers.isEmpty) return null; + if (!_folderPageController.hasClients) { + return _folderChatScrollControllers.first; + } + final p = _folderPageController.page; + final i = (p != null + ? p.round() + : _selectedFolderIndex) + .clamp(0, _folderChatScrollControllers.length - 1); + return _folderChatScrollControllers[i]; + } + + void _jumpFolderPageToSelection() { + if (!_folderPageController.hasClients) return; + final target = _folderIndexForId(_selectedFolderId); + final current = _folderPageController.page?.round(); + if (current != target) { + _folderPageController.jumpToPage(target); + } + } + String _formatTime(int? timestamp) { if (timestamp == null || timestamp == 0) return ''; final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); @@ -351,12 +548,11 @@ class _ChatListScreenState extends State _storiesRevealController.forward(from: 0); } - bool _onStoriesScrollNotification(ScrollNotification n) { + bool _handleStoriesScrollNotification(ScrollNotification n) { if (_currentNavIndex != 0) return false; - if (!_scrollController.hasClients) return false; if (n is ScrollEndNotification) { - if (_scrollController.offset <= 0.5) { + if (n.metrics.pixels <= 0.5) { setState(() { _storiesOverscrollRevealArmed = true; }); @@ -394,59 +590,9 @@ class _ChatListScreenState extends State return false; } - void _onScroll() { - if (_scrollController.hasClients) { - final double offset = _scrollController.offset; - if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) { - setState(() { - _shouldCollapseSearch = true; - }); - } - - if (offset < 0) { - if (!_allowStoriesPullOverscrollTop()) { - return; - } - final dragRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); - if (_storiesRevealController.isAnimating) { - return; - } - if (!_storiesDockedOpen && offset.abs() >= _kStoriesPullTriggerPx) { - _startStoriesAutoReveal(dragRatio); - } else if (!_storiesDockedOpen) { - if (dragRatio != _pullRatio) { - setState(() { - _pullRatio = dragRatio; - }); - } - } - } else { - if (_storiesDockedOpen && - offset > 12 && - DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { - _startStoriesAutoClose(); - } - if (_storiesDockedOpen || _storiesRevealController.isAnimating) { - return; - } - final disarm = offset > 3 && _storiesOverscrollRevealArmed; - final clearPull = _pullRatio > 0; - if (disarm || clearPull) { - setState(() { - if (disarm) { - _storiesOverscrollRevealArmed = false; - } - if (clearPull) { - _pullRatio = 0.0; - } - }); - } - } - } - } - @override void dispose() { + _loginSub?.cancel(); _stateSub?.cancel(); _fabController.dispose(); _navPageAnimController.dispose(); @@ -454,7 +600,13 @@ class _ChatListScreenState extends State ..removeListener(_onStoriesRevealTick) ..removeStatusListener(_onStoriesRevealStatus) ..dispose(); - _scrollController.dispose(); + _folderPageController.dispose(); + while (_folderChatScrollControllers.isNotEmpty) { + final c = _folderChatScrollControllers.removeLast(); + final fn = _folderChatScrollListenerFns.removeLast(); + c.removeListener(fn); + c.dispose(); + } super.dispose(); } @@ -727,31 +879,85 @@ class _ChatListScreenState extends State ui.PointerDeviceKind.trackpad, }, ), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, - ), - physics: const BouncingScrollPhysics(), - children: [ - _buildFolderChip('Все чаты'), - const SizedBox(width: 8), - _buildFolderChip('Контакты'), - const SizedBox(width: 8), - _buildFolderChip('Пидоры'), - const SizedBox(width: 8), - _buildFolderChip('Каналы'), - const SizedBox(width: 8), - _buildFolderChip('Группы'), - const SizedBox(width: 8), - _buildFolderChip('Боты'), - const SizedBox(width: 8), - _buildFolderChip('Избранное'), - const SizedBox(width: 8), - _buildFolderChip('Архив'), - ], - ), + child: _showFoldersShimmer + ? _buildFolderStripShimmer(cs) + : ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + physics: const BouncingScrollPhysics(), + children: [ + for (var i = 0; i < _folders.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _buildFolderChip( + _folderChipLabel(_folders[i]), + folderId: _folders[i].id, + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildFolderChatPage(int pageIndex) { + final chats = _chatsForPageIndex(pageIndex); + final sc = _folderChatScrollControllers[pageIndex]; + return NotificationListener( + onNotification: (ScrollNotification n) { + if (_currentNavIndex != 0) return false; + if (!_folderPageController.hasClients) { + if (pageIndex != _selectedFolderIndex) return false; + } else { + final p = _folderPageController.page; + if (p == null) { + if (pageIndex != _selectedFolderIndex) return false; + } else { + final r = p.round().clamp(0, _folderPageCount - 1); + if (r != pageIndex) return false; + } + } + return _handleStoriesScrollNotification(n); + }, + child: CustomScrollView( + controller: sc, + physics: _StoriesScrollPhysics( + blockPositive: _shouldBlockPositiveScroll, + allowPullOverscrollTop: _allowStoriesPullOverscrollTop, + parent: const AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, + childCount: _isInitialLoading ? 10 : chats.length, + ), + ), + SliverPadding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewPaddingOf(context).bottom + 100, ), ), ], @@ -772,7 +978,8 @@ class _ChatListScreenState extends State const Duration(milliseconds: 300), ); } - if (_scrollController.hasClients && _scrollController.offset <= 0) { + final ac = _activeChatScrollController(); + if (ac != null && ac.hasClients && ac.offset <= 0) { if (pointerSignal.scrollDelta.dy < 0) { if (_allowStoriesPullOverscrollTop()) { _startStoriesAutoReveal(max(_pullRatio, 0.18)); @@ -783,51 +990,30 @@ class _ChatListScreenState extends State } } }, - child: NotificationListener( - onNotification: _onStoriesScrollNotification, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildPinnedChatsHeader(context), - Expanded( - child: CustomScrollView( - controller: _scrollController, - physics: _StoriesScrollPhysics( - blockPositive: _shouldBlockPositiveScroll, - allowPullOverscrollTop: _allowStoriesPullOverscrollTop, - parent: const AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, childCount: _isInitialLoading ? 10 : _chats.length), - ), - SliverPadding( - padding: EdgeInsets.only( - bottom: MediaQuery.viewPaddingOf(context).bottom + 100, - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPinnedChatsHeader(context), + Expanded( + child: PageView.builder( + controller: _folderPageController, + physics: _folderPageCount <= 1 + ? const NeverScrollableScrollPhysics() + : const BouncingScrollPhysics(), + onPageChanged: (i) { + if (_folders.isEmpty) return; + if (i < 0 || i >= _folders.length) return; + setState(() { + _selectedFolderId = _folders[i].id; + }); + }, + itemCount: _folderPageCount, + itemBuilder: (context, pageIndex) { + return _buildFolderChatPage(pageIndex); + }, ), - ], - ), + ), + ], ), ); } @@ -1272,11 +1458,70 @@ class _ChatListScreenState extends State ); } - Widget _buildFolderChip(String title) { + String _folderChipLabel(ChatFolder f) { + final e = f.emoji; + if (e != null && e.isNotEmpty) return '$e ${f.title}'; + return f.title; + } + + Widget _buildFolderStripShimmer(ColorScheme cs) { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + physics: const BouncingScrollPhysics(), + children: [ + _folderShimmerPill(cs, 88), + const SizedBox(width: 8), + _folderShimmerPill(cs, 72), + const SizedBox(width: 8), + _folderShimmerPill(cs, 96), + const SizedBox(width: 8), + _folderShimmerPill(cs, 64), + const SizedBox(width: 8), + _folderShimmerPill(cs, 80), + ], + ), + ); + }, + ); + } + + Widget _folderShimmerPill(ColorScheme cs, double width) { + return Container( + width: width, + height: 32, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + ); + } + + Widget _buildFolderChip(String title, {required String folderId}) { final cs = Theme.of(context).colorScheme; - bool isSelected = _selectedCategory == title; + final isSelected = _selectedFolderId == folderId; return GestureDetector( - onTap: () => setState(() => _selectedCategory = title), + onTap: () { + final i = _folders.indexWhere((f) => f.id == folderId); + if (i < 0) return; + setState(() => _selectedFolderId = folderId); + if (_folderPageController.hasClients) { + final cur = _folderPageController.page?.round(); + if (cur != i) { + _folderPageController.animateToPage( + i, + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } + } + }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( From ac39a374c30642078e3536aef63aa5a5de24845a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sat, 4 Apr 2026 22:05:50 +0700 Subject: [PATCH 040/323] =?UTF-8?q?=D1=83=20=D0=BC=D0=B5=D0=BD=D1=8F=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BC=D0=B5=D0=BD=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/calls.dart | 147 ++++++++ lib/backend/modules/contacts.dart | 41 +++ lib/frontend/screens/calls/calls_tab.dart | 345 +++++++++++++++++- .../screens/contacts/contacts_tab.dart | 194 +++++++++- 4 files changed, 709 insertions(+), 18 deletions(-) diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index e69de29..2182af0 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -0,0 +1,147 @@ +// Backend module for parsing calls from Komet platform +import '../../core/storage/app_database.dart'; +import 'contacts.dart'; +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; + +enum CallStatus { missed, canceled, outgoing, incoming } + +class CallLogEntry { + final String id; + final int accountId; + final int peerId; + final String name; + final String? avatarUrl; + final CallStatus status; + final int time; + final int count; + + const CallLogEntry({ + required this.id, + required this.accountId, + required this.peerId, + required this.name, + this.avatarUrl, + required this.status, + required this.time, + this.count = 1, + }); +} + +class CallsModule { + final Api _api; + + CallsModule(this._api); + + /// Fetch call history from opcode 79 + Future> fetchHistory( + int accountId, + int currentUserId, + ) async { + final response = await _api.sendRequest(Opcode.videoChatHistory, {}); + if (!response.isOk || response.payload is! Map) return []; + + final payload = response.payload as Map; + return parseHistoryPayload(payload, accountId, currentUserId); + } + + /// Парсинг истории звонков (opcode 79: videoChatHistory) + static Future> parseHistoryPayload( + Map payload, + int accountId, + int currentUserId, + ) async { + final history = payload['history']; + if (history is! List || history.isEmpty) return []; + + final recentContacts = await ContactsModule.getContacts(accountId); + final contactsMap = {for (final c in recentContacts) c.id: c}; + + print('DEBUG: Loaded ${recentContacts.length} contacts'); + print('DEBUG: Contact IDs: ${contactsMap.keys.toList()}'); + print('DEBUG: Current user ID: $currentUserId'); + + final List extractedCalls = []; + + for (final item in history.whereType()) { + final msg = item['message']; + if (msg is! Map) continue; + + final attaches = msg['attaches']; + if (attaches is! List || attaches.isEmpty) continue; + + final callAttach = attaches.firstWhere( + (a) => a is Map && a['_type'] == 'CALL', + orElse: () => null, + ); + + if (callAttach == null) continue; + + final senderId = (msg['sender'] as int?) ?? 0; + final isOutgoing = senderId == currentUserId; + + int peerId = 0; + if (isOutgoing) { + final contactIds = callAttach['contactIds']; + if (contactIds is List && contactIds.isNotEmpty) { + peerId = (contactIds.first as int?) ?? 0; + } + } else { + peerId = senderId; + } + + print( + 'DEBUG: Call - isOutgoing: $isOutgoing, peerId: $peerId, senderId: $senderId', + ); + final contact = contactsMap[peerId]; + print( + 'DEBUG: Contact found: ${contact != null}, firstName: "${contact?.firstName}", lastName: "${contact?.lastName}"', + ); + final status = _parseCallStatus(callAttach, isOutgoing); + final time = (msg['time'] as int?) ?? 0; + final msgId = + msg['id']?.toString() ?? + DateTime.now().millisecondsSinceEpoch.toString(); + + final name = contact?.firstName != null + ? '${contact!.firstName} ${contact.lastName ?? ''}'.trim() + : 'Неизвестный'; + + print('DEBUG: Creating CallLogEntry with name: "$name"'); + + extractedCalls.add( + CallLogEntry( + id: msgId, + accountId: accountId, + peerId: peerId, + name: name, + avatarUrl: contact?.baseUrl, + status: status, + time: time, + ), + ); + } + + return extractedCalls; + } + + static CallStatus _parseCallStatus( + Map callAttach, + bool isOutgoing, + ) { + final hangupType = callAttach['hangupType']; + final duration = (callAttach['duration'] as int?) ?? 0; + + if (isOutgoing) { + if (hangupType == 'CANCELED' || duration == 0) return CallStatus.canceled; + return CallStatus.outgoing; + } else { + if (hangupType == 'CANCELED' || + hangupType == 'REJECTED' || + hangupType == 'MISSED' || + duration == 0) + return CallStatus.missed; + return CallStatus.incoming; + } + } +} diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 7d6614a..1943e52 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -1,5 +1,41 @@ import '../../core/storage/app_database.dart'; +class CachedContact { + final int id; + final int accountId; + final String firstName; + final String? lastName; + final int phone; + final int? photoId; + final String? baseUrl; + final String? baseRawUrl; + final int updateTime; + + const CachedContact({ + required this.id, + required this.accountId, + required this.firstName, + this.lastName, + required this.phone, + this.photoId, + this.baseUrl, + this.baseRawUrl, + required this.updateTime, + }); + + factory CachedContact.fromDbRow(Map row) => CachedContact( + id: row['id'] as int, + accountId: row['account_id'] as int, + firstName: row['first_name'] as String, + lastName: row['last_name'] as String?, + phone: row['phone'] as int, + photoId: row['photo_id'] as int?, + baseUrl: row['base_url'] as String?, + baseRawUrl: row['base_raw_url'] as String?, + updateTime: row['update_time'] as int, + ); +} + class ContactsModule { static Future syncFromLoginPayload( Map data, @@ -19,6 +55,11 @@ class ContactsModule { } } + static Future> getContacts(int accountId) async { + final rows = await AppDatabase.loadContacts(accountId); + return rows.map(CachedContact.fromDbRow).toList(); + } + static Map? _parseContact( Map contact, int accountId, diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 21bf27b..241bc6a 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -1,19 +1,346 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show api; +import '../../../core/storage/app_database.dart'; +import '../../../backend/modules/calls.dart'; -class CallsTab extends StatelessWidget { +class CallsTab extends StatefulWidget { const CallsTab({super.key}); + @override + State createState() => _CallsTabState(); +} + +class _CallsTabState extends State { + List _calls = []; + bool _isLoading = true; + int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные' + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _loadHistory() async { + final p = await AppDatabase.loadActiveProfile(); + if (p == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + + final callsModule = CallsModule(api); + final calls = await callsModule.fetchHistory(p.id, p.id); + + print('DEBUG UI: Received ${calls.length} calls'); + for (final call in calls.take(3)) { + print('DEBUG UI: Call name="${call.name}", peerId=${call.peerId}'); + } + + // Группируем подряд идущие звонки одному и тому же абоненту в один день с одним и тем же статусом + final List grouped = []; + for (final call in calls) { + if (grouped.isNotEmpty && + grouped.last.peerId == call.peerId && + grouped.last.status == call.status && + _isSameDay(grouped.last.time, call.time)) { + final last = grouped.removeLast(); + grouped.add( + CallLogEntry( + id: last.id, + accountId: last.accountId, + peerId: last.peerId, + name: last.name, + avatarUrl: last.avatarUrl, + status: last.status, + time: last.time, + count: last.count + 1, + ), + ); + } else { + grouped.add(call); + } + } + + if (mounted) { + setState(() { + _calls = grouped; + _isLoading = false; + }); + } + } + + bool _isSameDay(int time1, int time2) { + if (time1 == 0 || time2 == 0) return false; + final d1 = DateTime.fromMillisecondsSinceEpoch(time1); + final d2 = DateTime.fromMillisecondsSinceEpoch(time2); + return d1.year == d2.year && d1.month == d2.month && d1.day == d2.day; + } + + String _formatDate(int timestamp) { + if (timestamp == 0) return ''; + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final months = [ + 'янв.', + 'фев.', + 'мар.', + 'апр.', + 'мая', + 'июн.', + 'июл.', + 'авг.', + 'сен.', + 'окт.', + 'ноя.', + 'дек.', + ]; + return '${dt.day} ${months[dt.month - 1]}'; + } + + Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { + return Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildCallItem( + BuildContext context, + ColorScheme cs, + CallLogEntry call, + ) { + final bool isMissed = call.status == CallStatus.missed; + + String statusText; + IconData statusIcon; + switch (call.status) { + case CallStatus.missed: + statusText = 'Пропущенный'; + statusIcon = Symbols.phone_missed; + break; + case CallStatus.canceled: + statusText = 'Отменённый'; + statusIcon = Symbols.phone_disabled; + break; + case CallStatus.outgoing: + statusText = 'Исходящий'; + statusIcon = Symbols.call_made; + break; + case CallStatus.incoming: + statusText = 'Входящий'; + statusIcon = Symbols.call_received; + break; + } + + final String displayName = call.count > 1 + ? '${call.name} (${call.count})' + : call.name; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Open call details or initiate call + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.1), + width: 1, + ), + ), + child: ClipOval( + child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty + ? Image.network( + call.avatarUrl!, + fit: BoxFit.cover, + errorBuilder: (context, _, ___) => + _buildPlaceholderAvatar(cs, call.name), + ) + : _buildPlaceholderAvatar(cs, call.name), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: TextStyle( + color: isMissed ? cs.error : cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(statusIcon, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + statusText, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + _formatDate(call.time), + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + fontSize: 12, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTabItem(String label, int index, ColorScheme cs) { + final isSelected = _selectedTabIndex == index; + return GestureDetector( + onTap: () { + setState(() { + _selectedTabIndex = index; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected ? cs.primary : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? cs.primary : cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Center( - child: Text( - 'Звонки (Заглушка)', - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w500, + + final filteredCalls = _selectedTabIndex == 1 + ? _calls.where((c) => c.status == CallStatus.missed).toList() + : _calls; + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + 'Звонки', + style: TextStyle( + color: cs.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + InkWell( + onTap: () {}, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + child: Row( + children: [ + Icon(Symbols.link, color: cs.primary, size: 24), + const SizedBox(width: 16), + Text( + 'Создать групповой звонок', + style: TextStyle( + color: cs.primary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + child: Row( + children: [ + _buildTabItem('Все', 0, cs), + const SizedBox(width: 8), + _buildTabItem('Пропущенные', 1, cs), + ], + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : filteredCalls.isEmpty + ? Center( + child: Text( + 'Нет звонков', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 120), + itemCount: filteredCalls.length, + itemBuilder: (context, index) { + return _buildCallItem( + context, + cs, + filteredCalls[index], + ); + }, + ), + ), + ], ), ), ); diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index c47b562..fffa416 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -1,19 +1,195 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../backend/modules/contacts.dart'; -class ContactsTab extends StatelessWidget { +class ContactsTab extends StatefulWidget { const ContactsTab({super.key}); + @override + State createState() => _ContactsTabState(); +} + +class _ContactsTabState extends State { + List _contacts = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadContacts(); + } + + Future _loadContacts() async { + final p = await AppDatabase.loadActiveProfile(); + if (p == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + final contacts = await ContactsModule.getContacts(p.id); + // Sort contacts by first name + contacts.sort((a, b) => a.firstName.compareTo(b.firstName)); + if (mounted) { + setState(() { + _contacts = contacts; + _isLoading = false; + }); + } + } + + Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { + return Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildContactItem( + BuildContext context, + ColorScheme cs, + CachedContact contact, + ) { + final fullName = + '${contact.firstName}${contact.lastName != null ? ' ${contact.lastName}' : ''}' + .trim(); + final nameToDisplay = fullName.isEmpty ? '+${contact.phone}' : fullName; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Open contact details or chat + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.1), + width: 1, + ), + ), + child: ClipOval( + child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty + ? Image.network( + contact.baseUrl!, + fit: BoxFit.cover, + errorBuilder: (context, _, ___) => + _buildPlaceholderAvatar(cs, nameToDisplay), + ) + : _buildPlaceholderAvatar(cs, nameToDisplay), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nameToDisplay, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + contact.updateTime > 0 + ? 'Был(а) недавно' + : '+${contact.phone}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Center( - child: Text( - 'Контакты (Заглушка)', - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w500, + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Row( + children: [ + Expanded( + child: Text( + 'Контакты', + style: TextStyle( + color: cs.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + IconButton( + icon: Icon(Symbols.person_add, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.search, color: cs.onSurface), + onPressed: () {}, + ), + ], + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _contacts.isEmpty + ? Center( + child: Text( + 'Нет контактов', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 120), + itemCount: _contacts.length, + itemBuilder: (context, index) { + final contact = _contacts[index]; + return _buildContactItem(context, cs, contact); + }, + ), + ), + ], ), ), ); From 095155fcf9c5e7eb6585d936fa2afd11c37627e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sat, 4 Apr 2026 22:15:55 +0700 Subject: [PATCH 041/323] Fix code analysis issues: remove unused imports, debug prints, fix deprecated methods and naming conventions --- lib/backend/api.dart | 3 +- lib/backend/modules/calls.dart | 16 +---- lib/core/protocol/opcode_map.dart | 6 +- lib/core/protocol/packet.dart | 6 +- lib/core/storage/app_database.dart | 1 - lib/frontend/screens/calls/calls_tab.dart | 8 +-- .../screens/chats/chat_list_screen.dart | 62 +++++++++---------- .../screens/contacts/contacts_tab.dart | 2 +- .../screens/profile/settings_tab.dart | 2 +- lib/main.dart | 2 +- 10 files changed, 42 insertions(+), 66 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 72f987c..6a84839 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -14,7 +14,6 @@ import '../core/utils/logger.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'dart:io'; import 'package:timezone/data/latest_all.dart' as tz; -import 'package:timezone/timezone.dart' as tz; import 'package:flutter_timezone/flutter_timezone.dart'; enum SessionState { disconnected, connecting, connected, online } @@ -279,7 +278,7 @@ class Api { final delaySec = (2 * (1 << _reconnectAttempts)).clamp(2, 30); _reconnectAttempts++; - logger.i('Реконнект через ${delaySec}с (попытка $_reconnectAttempts)'); + logger.i('Реконнект через $delaySecс (попытка $_reconnectAttempts)'); _reconnectTimer?.cancel(); _reconnectTimer = Timer(Duration(seconds: delaySec), connect); diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 2182af0..4593e48 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -1,5 +1,4 @@ // Backend module for parsing calls from Komet platform -import '../../core/storage/app_database.dart'; import 'contacts.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; @@ -57,10 +56,6 @@ class CallsModule { final recentContacts = await ContactsModule.getContacts(accountId); final contactsMap = {for (final c in recentContacts) c.id: c}; - print('DEBUG: Loaded ${recentContacts.length} contacts'); - print('DEBUG: Contact IDs: ${contactsMap.keys.toList()}'); - print('DEBUG: Current user ID: $currentUserId'); - final List extractedCalls = []; for (final item in history.whereType()) { @@ -90,13 +85,7 @@ class CallsModule { peerId = senderId; } - print( - 'DEBUG: Call - isOutgoing: $isOutgoing, peerId: $peerId, senderId: $senderId', - ); final contact = contactsMap[peerId]; - print( - 'DEBUG: Contact found: ${contact != null}, firstName: "${contact?.firstName}", lastName: "${contact?.lastName}"', - ); final status = _parseCallStatus(callAttach, isOutgoing); final time = (msg['time'] as int?) ?? 0; final msgId = @@ -107,8 +96,6 @@ class CallsModule { ? '${contact!.firstName} ${contact.lastName ?? ''}'.trim() : 'Неизвестный'; - print('DEBUG: Creating CallLogEntry with name: "$name"'); - extractedCalls.add( CallLogEntry( id: msgId, @@ -139,8 +126,9 @@ class CallsModule { if (hangupType == 'CANCELED' || hangupType == 'REJECTED' || hangupType == 'MISSED' || - duration == 0) + duration == 0) { return CallStatus.missed; + } return CallStatus.incoming; } } diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index 75cc983..edc40e5 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -2,7 +2,7 @@ /// /// Naming follows the server-side convention. /// Use [Opcode.name] to get a human-readable label for logging. -/// +/// /// файл писла нейронка (я че ебанутый чтоль чтобы вручную хуярить опкоды и их значения) abstract class Opcode { // ── Session ──────────────────────────────────────────────────────── @@ -124,7 +124,7 @@ abstract class Opcode { // ── Sessions ─────────────────────────────────────────────────────── static const int sessionsInfo = 96; // Запрос активных сессий static const int sessionsClose = 97; // Закрытие всех сессий - static const int phoneBind_request = 98; // Запрос привязки телефона + static const int phoneBindRequest = 98; // Запрос привязки телефона static const int phoneBindConfirm = 99; // Подтверждение привязки телефона // ── Bots ─────────────────────────────────────────────────────────── @@ -297,7 +297,7 @@ abstract class Opcode { audioPlay: 'AUDIO_PLAY', sessionsInfo: 'SESSIONS_INFO', sessionsClose: 'SESSIONS_CLOSE', - phoneBind_request: 'PHONE_BIND_REQUEST', + phoneBindRequest: 'PHONE_BIND_REQUEST', phoneBindConfirm: 'PHONE_BIND_CONFIRM', chatComplain: 'CHAT_COMPLAIN', msgSendCallback: 'MSG_SEND_CALLBACK', diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 8bd0bc4..7a0a320 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -137,9 +137,7 @@ Future unpackPacket(Uint8List packet) async { _maxDecompressedSize, ); } catch (e) { - // В изоляте нельзя использовать логгер, который пишет в терминал через зависимости Flutter, - // но простой print или throw сработает - print("LZ4 decompression error: $e"); + throw Exception("LZ4 decompression error: $e"); } } } @@ -148,7 +146,7 @@ Future unpackPacket(Uint8List packet) async { payload = msgpack.deserialize(payloadBytes); } catch (e) { if (payloadBytes.isNotEmpty) { - print("MsgPack deserialization error: $e"); + throw Exception("MsgPack deserialization error: $e"); } } } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 97b80a9..ccd6191 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:path/path.dart'; -import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; class ProfileData { diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 241bc6a..09edce7 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -32,12 +32,6 @@ class _CallsTabState extends State { final callsModule = CallsModule(api); final calls = await callsModule.fetchHistory(p.id, p.id); - print('DEBUG UI: Received ${calls.length} calls'); - for (final call in calls.take(3)) { - print('DEBUG UI: Call name="${call.name}", peerId=${call.peerId}'); - } - - // Группируем подряд идущие звонки одному и тому же абоненту в один день с одним и тем же статусом final List grouped = []; for (final call in calls) { if (grouped.isNotEmpty && @@ -169,7 +163,7 @@ class _CallsTabState extends State { ? Image.network( call.avatarUrl!, fit: BoxFit.cover, - errorBuilder: (context, _, ___) => + errorBuilder: (context, error, stackTrace) => _buildPlaceholderAvatar(cs, call.name), ) : _buildPlaceholderAvatar(cs, call.name), diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 321f739..1db8a08 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -4,7 +4,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; import 'chat_screen.dart'; import '../calls/calls_tab.dart'; @@ -25,8 +24,8 @@ class _StoriesScrollPhysics extends BouncingScrollPhysics { const _StoriesScrollPhysics({ required this.blockPositive, required this.allowPullOverscrollTop, - ScrollPhysics? parent, - }) : super(parent: parent); + super.parent, + }); @override _StoriesScrollPhysics applyTo(ScrollPhysics? ancestor) { @@ -382,10 +381,10 @@ class _ChatListScreenState extends State return _folderChatScrollControllers.first; } final p = _folderPageController.page; - final i = (p != null - ? p.round() - : _selectedFolderIndex) - .clamp(0, _folderChatScrollControllers.length - 1); + final i = (p != null ? p.round() : _selectedFolderIndex).clamp( + 0, + _folderChatScrollControllers.length - 1, + ); return _folderChatScrollControllers[i]; } @@ -795,10 +794,10 @@ class _ChatListScreenState extends State vertical: 8, ), decoration: BoxDecoration( - color: cs.errorContainer.withOpacity(0.3), + color: cs.errorContainer.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), border: Border.all( - color: cs.error.withOpacity(0.2), + color: cs.error.withValues(alpha: 0.2), ), ), child: Row( @@ -933,27 +932,24 @@ class _ChatListScreenState extends State ), slivers: [ SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, - childCount: _isInitialLoading ? 10 : chats.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, childCount: _isInitialLoading ? 10 : chats.length), ), SliverPadding( padding: EdgeInsets.only( @@ -1371,7 +1367,7 @@ class _ChatListScreenState extends State color: cs.surface, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 10, offset: const Offset(0, 2), ), @@ -1575,7 +1571,9 @@ class _ChatListScreenState extends State onLongPress: () => _toggleSelection(id), child: AnimatedContainer( duration: const Duration(milliseconds: 200), - color: isSelected ? cs.primary.withOpacity(0.08) : Colors.transparent, + color: isSelected + ? cs.primary.withValues(alpha: 0.08) + : Colors.transparent, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index fffa416..3d86580 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -87,7 +87,7 @@ class _ContactsTabState extends State { ? Image.network( contact.baseUrl!, fit: BoxFit.cover, - errorBuilder: (context, _, ___) => + errorBuilder: (context, error, stackTrace) => _buildPlaceholderAvatar(cs, nameToDisplay), ) : _buildPlaceholderAvatar(cs, nameToDisplay), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 1f6a0ba..6efef04 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -151,7 +151,7 @@ class _SettingsTabState extends State { ? Image.network( _profile!.baseUrl!, fit: BoxFit.cover, - errorBuilder: (context, _, __) => + errorBuilder: (context, error, stackTrace) => _buildPlaceholderAvatar(cs, name), ) : _buildPlaceholderAvatar(cs, name), diff --git a/lib/main.dart b/lib/main.dart index e95dcfa..979aec2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -74,7 +74,7 @@ class KometAppState extends State { final navState = KometApp.navigatorKey.currentState; if (navState != null) { final overlayContext = navState.overlay?.context; - if (overlayContext != null) { + if (overlayContext != null && overlayContext.mounted) { showCustomNotification(overlayContext, e.message); } From f5f1c873f8f409bdb9d3d0beea2ffd59643b5b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sat, 4 Apr 2026 23:50:04 +0700 Subject: [PATCH 042/323] =?UTF-8?q?=D1=87=D0=B5=D1=82=D0=BE=20=D1=81=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BF=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=BD=D0=B0=D1=85?= =?UTF-8?q?=D1=83=D0=B5=D0=B2=D1=91=D1=80=D1=82=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 168 ++++++++++++------ 1 file changed, 115 insertions(+), 53 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1db8a08..4ac2dc8 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -201,8 +201,21 @@ class _ChatListScreenState extends State final p = await AppDatabase.loadActiveProfile(); if (p != null) { final chats = await ChatsModule.getChats(p.id); - final folders = await FoldersModule.loadFolders(p.id); + var folders = await FoldersModule.loadFolders(p.id); final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); + + final allChatsFolder = ChatFolder( + id: 'all.chat.folder', + title: 'Все чаты', + filters: [], + hideEmpty: false, + widgets: [], + ); + + if (!folders.any((f) => FoldersModule.isAllChatsFolder(f))) { + folders = [allChatsFolder, ...folders]; + } + final pageCount = folders.isEmpty ? 1 : folders.length; _syncFolderChatScrollControllersForCount(pageCount); if (mounted) { @@ -865,40 +878,75 @@ class _ChatListScreenState extends State ), ), ), - AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - height: 48, - color: cs.surface, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - ui.PointerDeviceKind.touch, - ui.PointerDeviceKind.mouse, - ui.PointerDeviceKind.trackpad, - }, - ), - child: _showFoldersShimmer - ? _buildFolderStripShimmer(cs) - : ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, + if (_folders.length > 1) + AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + height: 48, + color: cs.surface, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + ui.PointerDeviceKind.touch, + ui.PointerDeviceKind.mouse, + ui.PointerDeviceKind.trackpad, + }, + ), + child: _showFoldersShimmer + ? _buildFolderStripShimmer(cs) + : LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth - 40; + final folderCount = _folders.length; + final minWidthPerFolder = 80.0; + final totalMinWidth = + folderCount * minWidthPerFolder + + (folderCount - 1) * 8; + final needsScroll = totalMinWidth > availableWidth; + + if (needsScroll) { + return ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + physics: const BouncingScrollPhysics(), + children: [ + for (var i = 0; i < _folders.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _buildFolderChip( + _folderChipLabel(_folders[i]), + folderId: _folders[i].id, + ), + ], + ], + ); + } else { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + child: Row( + children: [ + for (var i = 0; i < _folders.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded( + child: _buildFolderChip( + _folderChipLabel(_folders[i]), + folderId: _folders[i].id, + ), + ), + ], + ], + ), + ); + } + }, ), - physics: const BouncingScrollPhysics(), - children: [ - for (var i = 0; i < _folders.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - _buildFolderChip( - _folderChipLabel(_folders[i]), - folderId: _folders[i].id, - ), - ], - ], - ), + ), ), - ), ], ), ); @@ -907,6 +955,7 @@ class _ChatListScreenState extends State Widget _buildFolderChatPage(int pageIndex) { final chats = _chatsForPageIndex(pageIndex); final sc = _folderChatScrollControllers[pageIndex]; + final cs = Theme.of(context).colorScheme; return NotificationListener( onNotification: (ScrollNotification n) { if (_currentNavIndex != 0) return false; @@ -931,26 +980,39 @@ class _ChatListScreenState extends State parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, childCount: _isInitialLoading ? 10 : chats.length), - ), + if (chats.isEmpty && !_isInitialLoading) + SliverFillRemaining( + child: Center( + child: Text( + 'Кажется, тут пусто...', + style: TextStyle( + color: cs.onSurface.withOpacity(0.6), + fontSize: 16, + ), + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, childCount: _isInitialLoading ? 10 : chats.length), + ), SliverPadding( padding: EdgeInsets.only( bottom: MediaQuery.viewPaddingOf(context).bottom + 100, From 015adda0a15b324a8159842b6213c1f986ea29fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sun, 5 Apr 2026 00:20:16 +0700 Subject: [PATCH 043/323] =?UTF-8?q?=D0=B2=D1=81=D1=91=20=D1=8F=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B0=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 +- .../screens/profile/security_screen.dart | 397 ++++++++++++++++++ .../screens/profile/settings_tab.dart | 24 +- .../screens/profile/spoof_screen.dart | 333 +++++++++++++++ 4 files changed, 755 insertions(+), 2 deletions(-) create mode 100644 lib/frontend/screens/profile/security_screen.dart create mode 100644 lib/frontend/screens/profile/spoof_screen.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 08303a5..31f08e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "cmake.sourceDirectory": "/run/media/invisedivine/Drive/! My projects/Komet/linux/runner" + "cmake.sourceDirectory": "/run/media/invisedivine/Drive/! My projects/Komet/linux/runner", + "kiroAgent.configureMCP": "Disabled" } \ No newline at end of file diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart new file mode 100644 index 0000000..a2c84d0 --- /dev/null +++ b/lib/frontend/screens/profile/security_screen.dart @@ -0,0 +1,397 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/custom_notification.dart'; + +class SecurityScreen extends StatefulWidget { + const SecurityScreen({super.key}); + + @override + State createState() => _SecurityScreenState(); +} + +class _SecurityScreenState extends State { + bool _safeMode = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildTopSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSafeModeSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + child: _buildInfoLabel(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildOnlineSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + child: _buildBlacklistSection(cs), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface, size: 24, weight: 400), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Text( + 'Безопасность', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ], + ), + ); + } + + Widget _buildTopSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + _buildNavRow( + cs, + icon: Symbols.key, + label: 'Пароль для входа', + subtitle: 'Отключён', + trailing: _buildWarningBadge(cs), + isLast: false, + ), + _buildNavRow( + cs, + icon: Symbols.shield, + label: 'Семейная защита', + subtitle: 'Отключена', + isLast: true, + ), + ], + ), + ); + } + + Widget _buildSafeModeSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon(Symbols.lock, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Безопасный режим', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Доступно только в мобильном приложении', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: _safeMode, + onChanged: (v) => setState(() => _safeMode = v), + ), + ], + ), + ), + if (_safeMode) ...[ + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider(height: 1, thickness: 1, color: cs.outlineVariant.withValues(alpha: 0.35)), + ), + _buildSubRow(cs, label: 'Найти меня по номеру', value: 'Могут все', isLast: false), + _buildSubRow(cs, label: 'Позвонить', value: 'Могут все', isLast: false), + _buildSubRow(cs, label: 'Пригласить в чат', value: 'Могут контакты', isLast: false), + _buildSubRow(cs, label: 'Показывать контент', value: 'Весь', isLast: true), + ], + ], + ), + ); + } + + Widget _buildInfoLabel(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(left: 4, bottom: 0), + child: Text( + 'ИНФОРМАЦИЯ', + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ); + } + + Widget _buildOnlineSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: _buildNavRow( + cs, + icon: null, + label: 'Видеть статус «в сети»', + value: 'Никто', + isLast: true, + noIcon: true, + ), + ); + } + + Widget _buildBlacklistSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => showCustomNotification(context, 'Чёрный список'), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Чёрный список', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Список тех, кто не может вам писать, звонить и добавлять в чаты', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon(Symbols.chevron_right, color: cs.outline, size: 20, weight: 400), + ], + ), + ), + ), + ), + ); + } + + Widget _buildNavRow( + ColorScheme cs, { + required IconData? icon, + required String label, + String? subtitle, + String? value, + Widget? trailing, + required bool isLast, + bool noIcon = false, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => showCustomNotification(context, label), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + if (!noIcon) ...[ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + ], + Expanded( + child: subtitle != null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ) + : Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + if (value != null) + Text( + value, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + if (trailing != null) trailing, + const SizedBox(width: 4), + Icon(Symbols.chevron_right, color: cs.outline, size: 20, weight: 400), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider(height: 1, thickness: 1, color: cs.outlineVariant.withValues(alpha: 0.35)), + ), + ], + ); + } + + Widget _buildSubRow( + ColorScheme cs, { + required String label, + required String value, + required bool isLast, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => showCustomNotification(context, label), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + ), + Text( + value, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(width: 4), + Icon(Symbols.chevron_right, color: cs.outline, size: 18, weight: 400), + ], + ), + ), + ), + ), + if (!isLast) + Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + indent: 20, + endIndent: 20, + ), + ], + ); + } + + Widget _buildWarningBadge(ColorScheme cs) { + return Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: cs.error, + shape: BoxShape.circle, + ), + child: Icon(Symbols.priority_high, color: cs.onError, size: 14, weight: 700), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 6efef04..3dc116c 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; import 'devices_screen.dart'; +import 'security_screen.dart'; +import 'spoof_screen.dart'; class SettingsTab extends StatefulWidget { const SettingsTab({super.key}); @@ -75,9 +77,29 @@ class _SettingsTabState extends State { icon: Symbols.notifications_active, label: 'Уведомления и звук', ), - const _SettingsItem( + _SettingsItem( + icon: Symbols.shield_lock, + label: 'Подделка данных', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SpoofScreen(), + ), + ); + }, + ), + _SettingsItem( icon: Symbols.lock, label: 'Безопасность', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SecurityScreen(), + ), + ); + }, ), _SettingsItem( icon: Symbols.devices, diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart new file mode 100644 index 0000000..accabc4 --- /dev/null +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -0,0 +1,333 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../../core/config/spoof_data.dart'; +import '../../widgets/custom_notification.dart'; + +class SpoofScreen extends StatefulWidget { + const SpoofScreen({super.key}); + + @override + State createState() => _SpoofScreenState(); +} + +class _SpoofScreenState extends State { + final _deviceTypeController = TextEditingController(); + final _deviceNameController = TextEditingController(); + final _osVersionController = TextEditingController(); + final _screenController = TextEditingController(); + final _timezoneController = TextEditingController(); + final _localeController = TextEditingController(); + final _deviceLocaleController = TextEditingController(); + final _deviceIdController = TextEditingController(); + final _appVersionController = TextEditingController(); + final _buildNumberController = TextEditingController(); + final _architectureController = TextEditingController(); + final _pushDeviceTypeController = TextEditingController(); + final _mtInstanceIdController = TextEditingController(); + final _clientSessionIdController = TextEditingController(); + + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + final random = Random(); + + setState(() { + _deviceTypeController.text = prefs.getString('spoof_device_type') ?? SpoofData.deviceType; + _deviceNameController.text = prefs.getString('spoof_device_name') ?? + SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; + _osVersionController.text = prefs.getString('spoof_os_version') ?? + 'Android ${SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]}'; + _screenController.text = prefs.getString('spoof_screen') ?? + SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; + _timezoneController.text = prefs.getString('spoof_timezone') ?? SpoofData.timezone; + _localeController.text = prefs.getString('spoof_locale') ?? SpoofData.locale; + _deviceLocaleController.text = prefs.getString('spoof_device_locale') ?? 'ru'; + _deviceIdController.text = prefs.getString('spoof_device_id') ?? + SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; + _appVersionController.text = prefs.getString('spoof_app_version') ?? SpoofData.appVersion; + _buildNumberController.text = prefs.getString('spoof_build_number') ?? SpoofData.buildNumber; + _architectureController.text = prefs.getString('spoof_architecture') ?? + SpoofData.architectures[random.nextInt(SpoofData.architectures.length)]; + _pushDeviceTypeController.text = prefs.getString('spoof_push_device_type') ?? 'GCM'; + _mtInstanceIdController.text = prefs.getString('spoof_mt_instanceid') ?? + '550e8400-e29b-41d4-a716-446655440000'; + _clientSessionIdController.text = prefs.getString('spoof_client_session_id') ?? '42'; + _isLoading = false; + }); + } + + Future _saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('spoof_device_type', _deviceTypeController.text); + await prefs.setString('spoof_device_name', _deviceNameController.text); + await prefs.setString('spoof_os_version', _osVersionController.text); + await prefs.setString('spoof_screen', _screenController.text); + await prefs.setString('spoof_timezone', _timezoneController.text); + await prefs.setString('spoof_locale', _localeController.text); + await prefs.setString('spoof_device_locale', _deviceLocaleController.text); + await prefs.setString('spoof_device_id', _deviceIdController.text); + await prefs.setString('spoof_app_version', _appVersionController.text); + await prefs.setString('spoof_build_number', _buildNumberController.text); + await prefs.setString('spoof_architecture', _architectureController.text); + await prefs.setString('spoof_push_device_type', _pushDeviceTypeController.text); + await prefs.setString('spoof_mt_instanceid', _mtInstanceIdController.text); + await prefs.setString('spoof_client_session_id', _clientSessionIdController.text); + + if (mounted) { + showCustomNotification(context, 'Настройки сохранены'); + } + } + + Future _randomizeAll() async { + final random = Random(); + setState(() { + _deviceNameController.text = SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; + _osVersionController.text = 'Android ${SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]}'; + _screenController.text = SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; + _deviceIdController.text = SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; + _architectureController.text = SpoofData.architectures[random.nextInt(SpoofData.architectures.length)]; + }); + await _saveSettings(); + if (mounted) { + showCustomNotification(context, 'Данные рандомизированы'); + } + } + + @override + void dispose() { + _deviceTypeController.dispose(); + _deviceNameController.dispose(); + _osVersionController.dispose(); + _screenController.dispose(); + _timezoneController.dispose(); + _localeController.dispose(); + _deviceLocaleController.dispose(); + _deviceIdController.dispose(); + _appVersionController.dispose(); + _buildNumberController.dispose(); + _architectureController.dispose(); + _pushDeviceTypeController.dispose(); + _mtInstanceIdController.dispose(); + _clientSessionIdController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_isLoading) { + return Scaffold( + backgroundColor: cs.surface, + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildSection(cs, [ + _buildField(cs, 'Тип устройства', _deviceTypeController), + _buildField(cs, 'Имя устройства', _deviceNameController), + _buildField(cs, 'Версия ОС', _osVersionController), + _buildField(cs, 'Разрешение экрана', _screenController), + _buildField(cs, 'Архитектура', _architectureController), + _buildField(cs, 'ID устройства', _deviceIdController), + _buildField(cs, 'Часовой пояс', _timezoneController), + _buildField(cs, 'Локаль', _localeController), + _buildField(cs, 'Локаль устройства', _deviceLocaleController), + _buildField(cs, 'Версия приложения', _appVersionController), + _buildField(cs, 'Build Number', _buildNumberController), + _buildField(cs, 'Push Device Type', _pushDeviceTypeController), + _buildField(cs, 'MT Instance ID', _mtInstanceIdController), + _buildField(cs, 'Client Session ID', _clientSessionIdController, isLast: true), + ]), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), + child: _buildActionButtons(cs), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface, size: 24, weight: 400), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Text( + 'Подделка данных', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ], + ), + ); + } + + + + Widget _buildSection(ColorScheme cs, List children) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column(children: children), + ); + } + + Widget _buildField( + ColorScheme cs, + String label, + TextEditingController controller, { + bool isLast = false, + }) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + SizedBox( + width: 140, + child: Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: controller, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + decoration: InputDecoration( + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + isDense: true, + ), + ), + ), + ], + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildActionButtons(ColorScheme cs) { + return Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: OutlinedButton( + onPressed: _randomizeAll, + style: OutlinedButton.styleFrom( + foregroundColor: cs.onSurface, + side: BorderSide(color: cs.outline, width: 1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Symbols.shuffle, size: 18, weight: 400), + const SizedBox(width: 6), + const Text( + 'Рандомизировать', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: FilledButton( + onPressed: _saveSettings, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: const Text( + 'Сохранить', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ); + } +} From a683748a33cc10a79ef5317a945dc3ad659fcb1a Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 5 Apr 2026 13:28:22 +0300 Subject: [PATCH 044/323] fix(ui): center folder tab labels in chat list --- lib/frontend/screens/chats/chat_list_screen.dart | 2 ++ lib/frontend/widgets/custom_notification.dart | 5 ++++- lib/main.dart | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 4ac2dc8..1f1bf57 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1581,6 +1581,7 @@ class _ChatListScreenState extends State } }, child: Container( + alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, @@ -1588,6 +1589,7 @@ class _ChatListScreenState extends State ), child: Text( title, + textAlign: TextAlign.center, style: TextStyle( color: isSelected ? cs.onPrimaryContainer : cs.primary, fontSize: 14, diff --git a/lib/frontend/widgets/custom_notification.dart b/lib/frontend/widgets/custom_notification.dart index 85c6e91..5bdb22b 100644 --- a/lib/frontend/widgets/custom_notification.dart +++ b/lib/frontend/widgets/custom_notification.dart @@ -2,7 +2,10 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; void showCustomNotification(BuildContext context, String message) { - final overlay = Overlay.of(context); + showCustomNotificationOnOverlay(Overlay.of(context), message); +} + +void showCustomNotificationOnOverlay(OverlayState overlay, String message) { final entry = OverlayEntry( builder: (context) => CustomNotification(message: message), ); diff --git a/lib/main.dart b/lib/main.dart index 979aec2..84fcf52 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -73,9 +73,9 @@ class KometAppState extends State { final navState = KometApp.navigatorKey.currentState; if (navState != null) { - final overlayContext = navState.overlay?.context; - if (overlayContext != null && overlayContext.mounted) { - showCustomNotification(overlayContext, e.message); + final overlay = navState.overlay; + if (overlay != null) { + showCustomNotificationOnOverlay(overlay, e.message); } await navState.pushAndRemoveUntil( From a9ed2b485969e0b7c97662f2732d9e230eb4adc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sun, 5 Apr 2026 19:11:55 +0700 Subject: [PATCH 045/323] =?UTF-8?q?=D0=A4=D1=80=D0=BE=D0=BD=D1=82=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B5,=20=D0=BF=D0=BE=D0=BA=D0=B0=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82=D0=B5=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 17 +- lib/backend/modules/messages.dart | 12 +- lib/frontend/screens/chats/chat_screen.dart | 156 ++++- lib/frontend/widgets/message_bubble.dart | 725 ++++++++++++++++++++ lib/main.dart | 8 + 5 files changed, 884 insertions(+), 34 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 6a84839..402cf92 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -12,9 +12,9 @@ import '../core/transport/sender.dart'; import '../core/utils/logger.dart'; import 'package:device_info_plus/device_info_plus.dart'; -import 'dart:io'; -import 'package:timezone/data/latest_all.dart' as tz; import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:timezone/data/latest_all.dart' as tz; +import 'dart:io'; enum SessionState { disconnected, connecting, connected, online } @@ -239,6 +239,19 @@ class Api { _dispatcher.clearPending(); } + Future reconnectAndLogin() async { + await connect(); + if (_sessionState == SessionState.online && _onReconnectCallback != null) { + _onReconnectCallback!(); + } + } + + void Function()? _onReconnectCallback; + + void setReconnectCallback(void Function() callback) { + _onReconnectCallback = callback; + } + void _startPinging() { _pingTimer?.cancel(); _pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) { diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 2cc90a7..7473e76 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -151,11 +151,13 @@ class MessagesModule { text: m['text'] as String?, time: (m['time'] as int?) ?? 0, status: m['status'] as String?, - payload: m - .cast< - String, - dynamic - >(), // Сохраняем весь пакет для гибкости (аттачи и т.д.) + payload: m.cast(), ); } + + Future sendMessage(int accountId, int chatId, String text) async { + final payload = {'chatId': chatId, 'text': text}; + + await _api.sendRequest(Opcode.msgSend, payload); + } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e7dfa6d..3a611ab 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -5,6 +5,7 @@ import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; +import '../../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { final int chatId; @@ -25,10 +26,13 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); bool _hasText = false; bool _isLoading = true; + bool _isSending = false; late AnimationController _shimmerController; List _messages = []; + int _myId = 0; @override void initState() { @@ -44,16 +48,17 @@ class _ChatScreenState extends State Future _loadHistory() async { final activeProfile = await AppDatabase.loadActiveProfile(); - final myId = activeProfile?.id ?? 0; + _myId = activeProfile?.id ?? 0; final cachedRows = await AppDatabase.loadMessages( - myId, + _myId, widget.chatId, limit: 100, ); if (mounted && cachedRows.isNotEmpty) { setState(() { _messages = cachedRows.map((r) => CachedMessage.fromDbRow(r)).toList(); + _messages.sort((a, b) => a.time.compareTo(b.time)); if (api.state == SessionState.online) { _isLoading = false; } @@ -61,9 +66,9 @@ class _ChatScreenState extends State } try { - await messagesModule.fetchHistory(myId, widget.chatId); + await messagesModule.fetchHistory(_myId, widget.chatId); final updatedRows = await AppDatabase.loadMessages( - myId, + _myId, widget.chatId, limit: 100, ); @@ -72,6 +77,7 @@ class _ChatScreenState extends State _messages = updatedRows .map((r) => CachedMessage.fromDbRow(r)) .toList(); + _messages.sort((a, b) => a.time.compareTo(b.time)); _isLoading = false; }); } @@ -89,6 +95,7 @@ class _ChatScreenState extends State void dispose() { _messageController.removeListener(_onTextChanged); _messageController.dispose(); + _scrollController.dispose(); _shimmerController.dispose(); super.dispose(); } @@ -102,6 +109,73 @@ class _ChatScreenState extends State } } + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _myId == 0) return; + + setState(() { + _isSending = true; + }); + + try { + final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; + final now = DateTime.now().millisecondsSinceEpoch; + + final tempMessage = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'sending', + ); + + setState(() { + _messages.add(tempMessage); + _messageController.clear(); + _hasText = false; + }); + + _scrollToBottom(); + + await messagesModule.sendMessage(_myId, widget.chatId, text); + + final index = _messages.indexWhere((m) => m.id == tempId); + if (index != -1) { + setState(() { + _messages[index] = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'sent', + ); + }); + } + } catch (e) { + debugPrint('Error sending message: $e'); + } finally { + setState(() { + _isSending = false; + }); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -131,10 +205,7 @@ class _ChatScreenState extends State backgroundColor: cs.primaryContainer, child: Text( widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 12, - ), + style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), ), ), const SizedBox(width: 12), @@ -178,11 +249,9 @@ class _ChatScreenState extends State body: Column( children: [ Expanded( - child: - _isLoading || - (_messages.isEmpty && api.state != SessionState.online) + child: _isLoading && _messages.isEmpty ? _buildShimmerLoading() - : const SizedBox.shrink(), + : _buildMessagesList(), ), _buildInputArea(context), ], @@ -190,6 +259,44 @@ class _ChatScreenState extends State ); } + Widget _buildMessagesList() { + if (_messages.isEmpty) { + return Center( + child: Text( + 'No messages yet', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + return ListView.builder( + controller: _scrollController, + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[_messages.length - 1 - index]; + final isMe = message.senderId == _myId; + final prevMessage = index < _messages.length - 1 + ? _messages[_messages.length - 2 - index] + : null; + final nextMessage = index > 0 + ? _messages[_messages.length - index] + : null; + + return MessageBubble( + message: message, + isMe: isMe, + myId: _myId, + prevMessage: prevMessage, + nextMessage: nextMessage, + ); + }, + ); + } + Widget _buildShimmerLoading() { return AnimatedBuilder( animation: _shimmerController, @@ -316,20 +423,12 @@ class _ChatScreenState extends State child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon( - Symbols.face, - color: mutedIcon, - size: 24, - weight: 400, - ), + Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400), const SizedBox(width: 12), Expanded( child: TextField( controller: _messageController, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - ), + style: TextStyle(color: cs.onSurface, fontSize: 16), maxLines: null, keyboardType: TextInputType.multiline, textAlignVertical: TextAlignVertical.center, @@ -379,11 +478,14 @@ class _ChatScreenState extends State color: _hasText ? cs.primary : cs.surfaceContainerHighest, shape: BoxShape.circle, ), - child: Icon( - _hasText ? Symbols.send : Symbols.mic, - color: _hasText ? cs.onPrimary : cs.onSurface, - size: 24, - weight: 400, + child: GestureDetector( + onTap: _hasText ? _sendMessage : null, + child: Icon( + _hasText ? Symbols.send : Symbols.mic, + color: _hasText ? cs.onPrimary : cs.onSurface, + size: 24, + weight: 400, + ), ), ), ], diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index e69de29..208d29c 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -0,0 +1,725 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../backend/modules/messages.dart'; + +enum MessageType { text, attachment, voice } + +enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } + +class MessageBubble extends StatelessWidget { + final CachedMessage message; + final bool isMe; + final int myId; + final CachedMessage? prevMessage; + final CachedMessage? nextMessage; + + const MessageBubble({ + super.key, + required this.message, + required this.isMe, + required this.myId, + this.prevMessage, + this.nextMessage, + }); + + bool get isGroupedWithNext { + if (nextMessage == null) return false; + if (nextMessage!.senderId != message.senderId) return false; + final timeDiff = nextMessage!.time - message.time; + return timeDiff < 300000; + } + + BubbleShape get shape { + final hasPrevFromMe = prevMessage?.senderId == message.senderId; + final prevTimeDiff = hasPrevFromMe + ? message.time - prevMessage!.time + : 999999999; + + final hasNextFromMe = nextMessage?.senderId == message.senderId; + final nextTimeDiff = hasNextFromMe + ? nextMessage!.time - message.time + : 999999999; + + final bool groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000; + final bool groupedWithNext = hasNextFromMe && nextTimeDiff < 300000; + + if (!groupedWithPrev && !groupedWithNext) return BubbleShape.singleMiddle; + if (!groupedWithPrev && groupedWithNext) return BubbleShape.singleTop; + if (groupedWithPrev && !groupedWithNext) return BubbleShape.singleBottom; + + return BubbleShape.groupedMiddle; + } + + MessageType get contentType { + final payload = message.payload; + if (payload == null) return MessageType.text; + + final attachments = payload['attachments']; + if (attachments is List && attachments.isNotEmpty) + return MessageType.attachment; + + final voice = payload['voice']; + if (voice != null) return MessageType.voice; + + return MessageType.text; + } + + // скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие + BorderRadius get _borderRadius { + switch (shape) { + case BubbleShape.singleTop: + return BorderRadius.only( + topLeft: isMe ? Radius.circular(20) : Radius.circular(4), + topRight: isMe ? Radius.circular(4) : Radius.circular(20), + bottomLeft: isMe ? Radius.circular(4) : Radius.circular(4), + bottomRight: isMe ? Radius.circular(4) : Radius.circular(4), + ); + case BubbleShape.singleBottom: + return BorderRadius.only( + topLeft: isMe ? Radius.circular(4) : Radius.circular(4), + topRight: isMe ? Radius.circular(4) : Radius.circular(4), + bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), + bottomRight: isMe ? Radius.circular(4) : Radius.circular(20), + ); + case BubbleShape.singleMiddle: + return BorderRadius.only( + topLeft: isMe ? Radius.circular(20) : Radius.circular(20), + topRight: isMe ? Radius.circular(20) : Radius.circular(20), + bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), + bottomRight: isMe ? Radius.circular(4) : Radius.circular(20), + ); + case BubbleShape.groupedMiddle: + return BorderRadius.only( + topLeft: isMe ? Radius.circular(20) : Radius.circular(4), + topRight: isMe ? Radius.circular(4) : Radius.circular(4), + bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), + bottomRight: isMe ? Radius.circular(4) : Radius.circular(4), + ); + } + } + + // ВРОДЕ отступы между сообщениями, если они выглядят адекватно не трогайте пж я ради них кишку на шею намотал + // ОТСТУПЫ МЕЖДУ СООБЩЕНИЯМИ (top/bottom margin) + // Зависит от shape (группировка) И contentType (тип контента) + double get topMargin { + switch (contentType) { + case MessageType.text: + switch (shape) { + case BubbleShape.singleTop: + return 6; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.attachment: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 6; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.voice: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + } + return 4; + } + + double get bottomMargin { + switch (contentType) { + case MessageType.text: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.attachment: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.voice: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + } + return 4; + } + + // Внутренний отступ, размеры типа я хз + EdgeInsets get padding { + switch (contentType) { + case MessageType.text: + switch (shape) { + case BubbleShape.groupedMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); + case BubbleShape.singleTop: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleBottom: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + } + case MessageType.attachment: + switch (shape) { + case BubbleShape.groupedMiddle: + return const EdgeInsets.all(0); + case BubbleShape.singleTop: + return const EdgeInsets.all(0); + case BubbleShape.singleBottom: + return const EdgeInsets.all(0); + case BubbleShape.singleMiddle: + return const EdgeInsets.all(0); + } + case MessageType.voice: + switch (shape) { + case BubbleShape.groupedMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); + case BubbleShape.singleTop: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleBottom: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + } + } + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + + return Padding( + padding: EdgeInsets.only( + left: isMe ? 60 : 12, + right: isMe ? 12 : 60, + top: topMargin, + bottom: bottomMargin, + ), + child: Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE9E9EB)), + borderRadius: _borderRadius, + ), + padding: padding, + child: _buildContent(context), + ), + ), + ); + } + + Widget _buildContent(BuildContext context) { + switch (contentType) { + case MessageType.attachment: + return _buildAttachmentContent(context); + case MessageType.voice: + return _buildVoiceContent(context); + case MessageType.text: + default: + return _buildTextContent(context); + } + } + + Widget _buildTextContent(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + _formatTime(message.time), + style: TextStyle( + color: textColor.withValues(alpha: 0.7), + fontSize: 11, + ), + ), + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + ], + ); + } + + Widget _buildAttachmentContent(BuildContext context) { + final payload = message.payload; + final attachments = payload?['attachments'] as List?; + if (attachments == null || attachments.isEmpty) { + return _buildTextContent(context); + } + + final attachment = attachments.first as Map?; + if (attachment == null) return _buildTextContent(context); + + final type = attachment['type'] as String?; + final url = attachment['url'] ?? attachment['path']; + final thumbnail = attachment['thumbnail']; + + if (type == 'image' || type == 'photo') { + return _buildImageAttachment(context, url, thumbnail); + } else if (type == 'video') { + return _buildVideoAttachment(context, url, attachment['duration']); + } else if (type == 'file') { + return _buildFileAttachment(context, attachment['name'] ?? 'File', url); + } + + return _buildTextContent(context); + } + + Widget _buildImageAttachment( + BuildContext ctx, + dynamic url, + dynamic thumbnail, + ) { + final cs = Theme.of(ctx).colorScheme; + final imageUrl = url?.toString() ?? ''; + final thumbUrl = thumbnail?.toString() ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + if (thumbUrl.isNotEmpty) + Image.network( + thumbUrl, + width: 200, + height: 200, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs), + ) + else if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + width: 200, + height: 200, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs), + ) + else + _buildImagePlaceholder(cs), + if (imageUrl.isNotEmpty) + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () {}), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + _buildMeta(ctx), + ], + ); + } + + Widget _buildImagePlaceholder(ColorScheme cs) { + return Container( + width: 200, + height: 200, + color: cs.surfaceContainerHighest, + child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant), + ); + } + + Widget _buildVideoAttachment( + BuildContext ctx, + dynamic url, + dynamic duration, + ) { + final cs = Theme.of(ctx).colorScheme; + final videoUrl = url?.toString() ?? ''; + final durationSec = duration as int? ?? 0; + final durationStr = _formatDuration(durationSec); + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + Container( + width: 200, + height: 150, + color: cs.surfaceContainerHighest, + child: Icon( + Symbols.videocam, + size: 48, + color: cs.onSurfaceVariant, + ), + ), + Positioned( + bottom: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + durationStr, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + if (videoUrl.isNotEmpty) + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () {}), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + _buildMeta(ctx), + ], + ); + } + + Widget _buildFileAttachment(BuildContext ctx, String fileName, dynamic url) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + width: 220, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isMe + ? Colors.white.withValues(alpha: 0.15) + : (isDark ? cs.surface : const Color(0xFFFFFFFF)), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isMe + ? Colors.white.withValues(alpha: 0.3) + : cs.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isMe + ? Colors.white.withValues(alpha: 0.2) + : cs.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Symbols.description, + color: isMe ? Colors.white : cs.primary, + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'Tap to download', + style: TextStyle( + color: textColor.withValues(alpha: 0.6), + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 6), + _buildMeta(ctx), + ], + ); + } + + Widget _buildVoiceContent(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final payload = message.payload; + final voice = payload?['voice'] as Map?; + final duration = voice?['duration'] as int? ?? 0; + final url = voice?['url']?.toString() ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _VoiceMessageBubble( + duration: duration, + url: url, + textColor: textColor, + isMe: isMe, + ), + const SizedBox(height: 6), + _buildMeta(context), + ], + ); + } + + Widget _buildMeta(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final timeColor = isMe ? Colors.white70 : cs.onSurfaceVariant; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatTime(message.time), + style: TextStyle(color: timeColor, fontSize: 11), + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + ], + ), + ); + } + + Widget _buildStatusIcon(BuildContext context) { + final status = message.status; + IconData icon; + Color color; + + if (status == null || status == 'sending' || status == 'pending') { + icon = Symbols.check; + color = Colors.white70; + } else { + switch (status) { + case 'sent': + icon = Symbols.check; + color = Colors.white70; + case 'delivered': + icon = Symbols.done_all; + color = Colors.white70; + case 'read': + icon = Symbols.done_all; + color = const Color(0xFF34C759); + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = Colors.white70; + } + } + + return Icon(icon, size: 14, color: color); + } + + String _formatTime(int timestamp) { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + String _formatDuration(int seconds) { + final min = seconds ~/ 60; + final sec = seconds % 60; + return '$min:${sec.toString().padLeft(2, '0')}'; + } +} + +class _VoiceMessageBubble extends StatefulWidget { + final int duration; + final String url; + final Color textColor; + final bool isMe; + + const _VoiceMessageBubble({ + required this.duration, + required this.url, + required this.textColor, + required this.isMe, + }); + + @override + State<_VoiceMessageBubble> createState() => _VoiceMessageBubbleState(); +} + +class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { + bool _isPlaying = false; + double _progress = 0.0; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + + return Container( + width: 220, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + GestureDetector( + onTap: _togglePlay, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.2) + : cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + _isPlaying ? Symbols.pause : Symbols.play_arrow, + color: widget.isMe ? Colors.white : cs.primary, + size: 20, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Container( + height: 24, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFD1D1D6)), + borderRadius: BorderRadius.circular(2), + ), + ), + FractionallySizedBox( + widthFactor: _progress.clamp(0.0, 1.0), + child: Container( + height: 24, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.5) + : cs.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + SizedBox( + height: 24, + child: Center( + child: Text( + _formatDuration(widget.duration), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.8), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + void _togglePlay() { + setState(() { + _isPlaying = !_isPlaying; + }); + } + + String _formatDuration(int seconds) { + final min = seconds ~/ 60; + final sec = seconds % 60; + return '$min:${sec.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/main.dart b/lib/main.dart index 84fcf52..691248e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -62,6 +62,14 @@ class KometAppState extends State { void initState() { super.initState(); _locale = widget.initialLocale; + + api.setReconnectCallback(() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await accountModule.login(accountId: accountId); + } + }); + api.sessionExpiredStream.listen((SessionExpiredException e) async { if (_isLoggingOut) return; _isLoggingOut = true; From 35cb0e030034ad043550a0b31c80a7c1ff29061d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sun, 5 Apr 2026 21:13:38 +0700 Subject: [PATCH 046/323] =?UTF-8?q?=D0=B5=D0=B1=D0=B0=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=20=D0=BE=D0=BD=D0=BE=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8F=20=D0=B7=D0=B0=D0=B5=D0=B1=D0=B0=D0=BB=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 139 +++++ lib/frontend/widgets/message_bubble.dart | 614 ++++++++++++++++++----- lib/models/attachment.dart | 395 +++++++++++++++ 3 files changed, 1014 insertions(+), 134 deletions(-) create mode 100644 lib/models/attachment.dart diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 7473e76..483cd94 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'dart:typed_data'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; +import '../../models/attachment.dart'; class CachedMessage { final String id; @@ -12,6 +14,7 @@ class CachedMessage { final int time; final String? status; final Map? payload; + final List? attachments; const CachedMessage({ required this.id, @@ -22,6 +25,7 @@ class CachedMessage { required this.time, this.status, this.payload, + this.attachments, }); factory CachedMessage.fromDbRow(Map row) { @@ -33,6 +37,16 @@ class CachedMessage { } catch (_) {} } + List? attachments; + if (payload != null) { + final attaches = payload['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .map((a) => MessageAttachment.fromMap(a as Map)) + .toList(); + } + } + return CachedMessage( id: row['id'] as String, accountId: row['account_id'] as int, @@ -42,6 +56,7 @@ class CachedMessage { time: row['time'] as int, status: row['status'] as String?, payload: payload, + attachments: attachments, ); } @@ -143,6 +158,15 @@ class MessagesModule { final id = m['id']?.toString(); if (id == null) return null; + final attaches = m['attaches'] as List?; + List? attachments; + if (attaches != null) { + attachments = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(a.cast())) + .toList(); + } + return CachedMessage( id: id, accountId: accountId, @@ -152,6 +176,7 @@ class MessagesModule { time: (m['time'] as int?) ?? 0, status: m['status'] as String?, payload: m.cast(), + attachments: attachments, ); } @@ -160,4 +185,118 @@ class MessagesModule { await _api.sendRequest(Opcode.msgSend, payload); } + + Future downloadPhoto(String baseUrl, String photoToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': photoToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final content = data['content']; + if (content is String) { + return Uri.parse(content).host.isNotEmpty ? null : null; + } + return null; + } catch (e) { + return null; + } + } + + Future getPhotoUrl(String baseUrl, String photoToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': photoToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + return data['content'] as String?; + } catch (e) { + return null; + } + } + + Future downloadVideo(String baseUrl, String videoToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': videoToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final content = data['content']; + if (content is String) { + return Uri.parse(content).host.isNotEmpty ? null : null; + } + return null; + } catch (e) { + return null; + } + } + + Future getVideoUrl(String baseUrl, String videoToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': videoToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + return data['content'] as String?; + } catch (e) { + return null; + } + } + + Future downloadFile(String baseUrl, String fileToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': fileToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final content = data['content']; + if (content is String) { + return Uri.parse(content).host.isNotEmpty ? null : null; + } + return null; + } catch (e) { + return null; + } + } + + Future getFileUrl(String baseUrl, String fileToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': fileToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + return data['content'] as String?; + } catch (e) { + return null; + } + } } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 208d29c..ba7b4a1 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,12 +1,40 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; +import '../../models/attachment.dart'; enum MessageType { text, attachment, voice } enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } class MessageBubble extends StatelessWidget { + static const double photoMaxSize = 280.0; + static const double photoMinSize = 100.0; + static const double photoBorderRadius = 12.0; + static const double bubbleBorderRadius = 20.0; + static const double captionPaddingHorizontal = 6.0; + static const double captionPaddingRight = 4.0; + static const double compactTimePadding = 8.0; + + bool get _hasPhotoWithCaption { + if (message.attachments == null || message.attachments!.isEmpty) + return false; + final hasPhoto = message.attachments!.any((a) => a is PhotoAttachment); + final hasCaption = message.text != null && message.text!.isNotEmpty; + debugPrint( + 'DEBUG _hasPhotoWithCaption: hasPhoto=$hasPhoto, hasCaption=$hasCaption, text="${message.text}", shape=$shape', + ); + return hasPhoto && hasCaption; + } + + bool get _hasMultiplePhotosNoCaption { + if (message.attachments == null || message.attachments!.isEmpty) + return false; + final photoCount = message.attachments!.whereType().length; + final hasCaption = message.text != null && message.text!.isNotEmpty; + return photoCount >= 2 && !hasCaption; + } + final CachedMessage message; final bool isMe; final int myId; @@ -51,13 +79,15 @@ class MessageBubble extends StatelessWidget { } MessageType get contentType { + if (message.attachments != null && message.attachments!.isNotEmpty) { + final first = message.attachments!.first; + if (first.type == AttachmentType.audio) return MessageType.voice; + return MessageType.attachment; + } + final payload = message.payload; if (payload == null) return MessageType.text; - final attachments = payload['attachments']; - if (attachments is List && attachments.isNotEmpty) - return MessageType.attachment; - final voice = payload['voice']; if (voice != null) return MessageType.voice; @@ -66,34 +96,61 @@ class MessageBubble extends StatelessWidget { // скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие BorderRadius get _borderRadius { + final topRadius = Radius.circular(bubbleBorderRadius); + final bottomRadius = Radius.circular(bubbleBorderRadius); + final smallRadius = const Radius.circular(4); + + if (_hasPhotoWithCaption && + (shape == BubbleShape.singleTop || + shape == BubbleShape.singleMiddle || + shape == BubbleShape.singleBottom)) { + return BorderRadius.only( + topLeft: topRadius, + topRight: isMe ? topRadius : topRadius, + bottomLeft: smallRadius, + bottomRight: smallRadius, + ); + } + + if (_hasMultiplePhotosNoCaption && + (shape == BubbleShape.singleBottom || + shape == BubbleShape.singleMiddle)) { + return BorderRadius.only( + topLeft: smallRadius, + topRight: smallRadius, + bottomLeft: isMe ? smallRadius : smallRadius, + bottomRight: isMe ? smallRadius : bottomRadius, + ); + } + switch (shape) { case BubbleShape.singleTop: return BorderRadius.only( - topLeft: isMe ? Radius.circular(20) : Radius.circular(4), - topRight: isMe ? Radius.circular(4) : Radius.circular(20), - bottomLeft: isMe ? Radius.circular(4) : Radius.circular(4), - bottomRight: isMe ? Radius.circular(4) : Radius.circular(4), + topLeft: isMe ? topRadius : smallRadius, + topRight: isMe ? smallRadius : topRadius, + bottomLeft: smallRadius, + bottomRight: smallRadius, ); case BubbleShape.singleBottom: return BorderRadius.only( - topLeft: isMe ? Radius.circular(4) : Radius.circular(4), - topRight: isMe ? Radius.circular(4) : Radius.circular(4), - bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), - bottomRight: isMe ? Radius.circular(4) : Radius.circular(20), + topLeft: smallRadius, + topRight: smallRadius, + bottomLeft: isMe ? topRadius : smallRadius, + bottomRight: isMe ? smallRadius : topRadius, ); case BubbleShape.singleMiddle: return BorderRadius.only( - topLeft: isMe ? Radius.circular(20) : Radius.circular(20), - topRight: isMe ? Radius.circular(20) : Radius.circular(20), - bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), - bottomRight: isMe ? Radius.circular(4) : Radius.circular(20), + topLeft: topRadius, + topRight: topRadius, + bottomLeft: isMe ? topRadius : smallRadius, + bottomRight: isMe ? smallRadius : topRadius, ); case BubbleShape.groupedMiddle: return BorderRadius.only( - topLeft: isMe ? Radius.circular(20) : Radius.circular(4), - topRight: isMe ? Radius.circular(4) : Radius.circular(4), - bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), - bottomRight: isMe ? Radius.circular(4) : Radius.circular(4), + topLeft: isMe ? topRadius : smallRadius, + topRight: isMe ? smallRadius : smallRadius, + bottomLeft: isMe ? topRadius : smallRadius, + bottomRight: isMe ? smallRadius : smallRadius, ); } } @@ -288,7 +345,7 @@ class MessageBubble extends StatelessWidget { _formatTime(message.time), style: TextStyle( color: textColor.withValues(alpha: 0.7), - fontSize: 11, + fontSize: 10, ), ), ), @@ -298,157 +355,393 @@ class MessageBubble extends StatelessWidget { } Widget _buildAttachmentContent(BuildContext context) { - final payload = message.payload; - final attachments = payload?['attachments'] as List?; + final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { return _buildTextContent(context); } - final attachment = attachments.first as Map?; - if (attachment == null) return _buildTextContent(context); - - final type = attachment['type'] as String?; - final url = attachment['url'] ?? attachment['path']; - final thumbnail = attachment['thumbnail']; - - if (type == 'image' || type == 'photo') { - return _buildImageAttachment(context, url, thumbnail); - } else if (type == 'video') { - return _buildVideoAttachment(context, url, attachment['duration']); - } else if (type == 'file') { - return _buildFileAttachment(context, attachment['name'] ?? 'File', url); + final photos = attachments.whereType().toList(); + if (photos.isEmpty) { + return _buildGenericAttachment(context, attachments.first); } - return _buildTextContent(context); + return _buildPhotoContent(context, photos); } - Widget _buildImageAttachment( - BuildContext ctx, - dynamic url, - dynamic thumbnail, - ) { - final cs = Theme.of(ctx).colorScheme; - final imageUrl = url?.toString() ?? ''; - final thumbUrl = thumbnail?.toString() ?? ''; + Widget _buildPhotoContent(BuildContext ctx, List photos) { + final hasCaption = message.text != null && message.text!.isNotEmpty; + final count = photos.length; + + Widget photosWidget; + if (count == 1) { + photosWidget = _buildSinglePhoto(ctx, photos[0]); + } else if (count == 2) { + photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]); + } else { + photosWidget = _buildPhotoGrid(ctx, photos); + } + + if (!hasCaption) { + return Stack( + children: [ + photosWidget, + Positioned( + bottom: compactTimePadding, + right: compactTimePadding, + child: _buildCompactTime(ctx), + ), + ], + ); + } + + if (count == 1) { + return IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + photosWidget, + Padding( + padding: const EdgeInsets.only( + left: captionPaddingHorizontal, + right: captionPaddingRight, + bottom: 6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _buildCaption(ctx)), + _buildMeta(ctx), + ], + ), + ), + ], + ), + ); + } return Column( - crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( + photosWidget, + Padding( + padding: const EdgeInsets.only( + left: captionPaddingHorizontal, + right: captionPaddingRight, + bottom: 6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (thumbUrl.isNotEmpty) - Image.network( - thumbUrl, - width: 200, - height: 200, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs), - ) - else if (imageUrl.isNotEmpty) - Image.network( - imageUrl, - width: 200, - height: 200, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs), - ) - else - _buildImagePlaceholder(cs), - if (imageUrl.isNotEmpty) - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell(onTap: () {}), - ), - ), + Expanded(child: _buildCaption(ctx)), + _buildMeta(ctx), ], ), ), - const SizedBox(height: 6), - _buildMeta(ctx), ], ); } - Widget _buildImagePlaceholder(ColorScheme cs) { - return Container( - width: 200, - height: 200, - color: cs.surfaceContainerHighest, - child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant), + Widget _buildSinglePhoto(BuildContext ctx, PhotoAttachment photo) { + final imageUrl = photo.baseUrl ?? ''; + final width = photo.width?.toDouble() ?? 200; + final height = photo.height?.toDouble() ?? 200; + + final constrainedWidth = width.clamp(photoMinSize, photoMaxSize); + final constrainedHeight = height.clamp(photoMinSize, photoMaxSize); + + final bool matchTop = _hasPhotoWithCaption; + final bool matchBottom = !_hasPhotoWithCaption; + + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + topRight: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + bottomLeft: Radius.circular( + matchBottom ? (isMe ? bubbleBorderRadius : 4) : 4, + ), + bottomRight: Radius.circular( + matchBottom ? (isMe ? 4 : bubbleBorderRadius) : 4, + ), + ), + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + width: constrainedWidth, + height: constrainedHeight, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildPhotoPlaceholder( + ctx, + constrainedWidth, + constrainedHeight, + ), + ) + else + _buildPhotoPlaceholder(ctx, constrainedWidth, constrainedHeight), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)), + ), + ), + ], + ), ); } - Widget _buildVideoAttachment( + Widget _buildTwoPhotos( BuildContext ctx, - dynamic url, - dynamic duration, + PhotoAttachment p1, + PhotoAttachment p2, ) { - final cs = Theme.of(ctx).colorScheme; - final videoUrl = url?.toString() ?? ''; - final durationSec = duration as int? ?? 0; - final durationStr = _formatDuration(durationSec); + final matchTop = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop; + final matchBottom = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - Container( - width: 200, - height: 150, - color: cs.surfaceContainerHighest, - child: Icon( - Symbols.videocam, - size: 48, - color: cs.onSurfaceVariant, - ), + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + topRight: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius), + bottomRight: Radius.circular( + matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildPhotoTile(ctx, p1), + const SizedBox(width: 2), + _buildPhotoTile(ctx, p2), + ], + ), + ); + } + + Widget _buildPhotoGrid(BuildContext ctx, List photos) { + final displayCount = photos.length > 4 ? 4 : photos.length; + final remaining = photos.length - 4; + + final matchTop = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop; + final matchBottom = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom; + + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + topRight: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius), + bottomRight: Radius.circular( + matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius, + ), + ), + child: GridView.count( + crossAxisCount: 2, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: List.generate(displayCount, (i) { + if (i == 3 && remaining > 0) { + return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining'); + } + return _buildPhotoTile(ctx, photos[i]); + }), + ), + ); + } + + Widget _buildPhotoTile(BuildContext ctx, PhotoAttachment photo) { + final imageUrl = photo.baseUrl ?? ''; + return Expanded( + child: AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, __, ___) => + _buildPhotoPlaceholder(ctx, 100, 100), + ) + else + _buildPhotoPlaceholder(ctx, 100, 100), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)), ), - Positioned( - bottom: 8, - left: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(4), - ), + ), + ], + ), + ), + ); + } + + Widget _buildPhotoTileWithOverlay( + BuildContext ctx, + PhotoAttachment photo, + String overlay, + ) { + final imageUrl = photo.baseUrl ?? ''; + return Expanded( + child: AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, __, ___) => + _buildPhotoPlaceholder(ctx, 100, 100), + ) + else + _buildPhotoPlaceholder(ctx, 100, 100), + Positioned.fill( + child: Container( + color: Colors.black45, + child: Center( child: Text( - durationStr, - style: const TextStyle(color: Colors.white, fontSize: 12), + overlay, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), ), ), ), - if (videoUrl.isNotEmpty) - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell(onTap: () {}), - ), - ), - ], - ), + ), + ], ), - const SizedBox(height: 6), - _buildMeta(ctx), - ], + ), ); } - Widget _buildFileAttachment(BuildContext ctx, String fileName, dynamic url) { + Widget _buildPhotoPlaceholder( + BuildContext ctx, + double w, + double h, { + VoidCallback? onRetry, + }) { + final cs = Theme.of(ctx).colorScheme; + return Container( + width: w, + height: h, + color: cs.surfaceContainerHighest, + child: onRetry != null + ? Center( + child: IconButton( + icon: Icon(Symbols.refresh, color: cs.onSurfaceVariant), + onPressed: onRetry, + tooltip: 'Retry', + ), + ) + : Center( + child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant), + ), + ); + } + + Widget _buildCaption(BuildContext ctx) { final cs = Theme.of(ctx).colorScheme; final isDark = cs.brightness == Brightness.dark; final textColor = isMe ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + return Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ); + } + + Widget _buildGenericAttachment( + BuildContext ctx, + MessageAttachment attachment, + ) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + + switch (attachment.type) { + case AttachmentType.video: + return _buildVideoAttachment(ctx, attachment); + case AttachmentType.file: + return _buildFileAttachment(ctx, attachment); + case AttachmentType.sticker: + return _buildStickerAttachment(ctx, attachment); + default: + return _buildTextContent(ctx); + } + } + + Widget _buildVideoAttachment(BuildContext ctx, MessageAttachment video) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(photoBorderRadius), + child: Stack( + children: [ + Container( + width: 200, + height: 150, + color: Theme.of(ctx).colorScheme.surfaceContainerHighest, + child: Icon( + Symbols.videocam, + size: 48, + color: Theme.of(ctx).colorScheme.onSurfaceVariant, + ), + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () {}), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + _buildMeta(ctx), + ], + ); + } + + Widget _buildFileAttachment(BuildContext ctx, MessageAttachment file) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final name = (file as dynamic).name as String? ?? 'File'; + return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -459,7 +752,7 @@ class MessageBubble extends StatelessWidget { color: isMe ? Colors.white.withValues(alpha: 0.15) : (isDark ? cs.surface : const Color(0xFFFFFFFF)), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(photoBorderRadius), border: Border.all( color: isMe ? Colors.white.withValues(alpha: 0.3) @@ -489,7 +782,7 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - fileName, + name, style: TextStyle( color: textColor, fontSize: 14, @@ -518,6 +811,33 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) { + final preview = (sticker as dynamic).previewData as String? ?? ''; + + return ClipRRect( + borderRadius: BorderRadius.circular(photoBorderRadius), + child: Stack( + children: [ + if (preview.isNotEmpty) + Image.network( + preview, + width: 150, + height: 150, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + _buildPhotoPlaceholder(ctx, 150, 150), + ) + else + _buildPhotoPlaceholder(ctx, 150, 150), + ], + ), + ); + } + + void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) { + // TODO: Open photo viewer + } + Widget _buildVoiceContent(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; @@ -553,6 +873,7 @@ class MessageBubble extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, children: [ Text( _formatTime(message.time), @@ -564,6 +885,31 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildCompactTime(BuildContext ctx) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final bgColor = isMe + ? Colors.black.withValues(alpha: 0.4) + : Colors.black.withValues(alpha: 0.5); + final textColor = Colors.white; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatTime(message.time), + style: TextStyle( + color: textColor, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ); + } + Widget _buildStatusIcon(BuildContext context) { final status = message.status; IconData icon; diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart new file mode 100644 index 0000000..2270210 --- /dev/null +++ b/lib/models/attachment.dart @@ -0,0 +1,395 @@ +enum AttachmentType { + photo, + video, + audio, + file, + contact, + location, + sticker, + control, +} + +abstract class MessageAttachment { + final AttachmentType type; + final String? previewData; + final String? baseUrl; + final String? fileUrl; + + const MessageAttachment({ + required this.type, + this.previewData, + this.baseUrl, + this.fileUrl, + }); + + factory MessageAttachment.fromMap(Map map) { + final type = (map['_type'] as String? ?? '').toUpperCase(); + switch (type) { + case 'PHOTO': + return PhotoAttachment.fromMap(map); + case 'VIDEO': + return VideoAttachment.fromMap(map); + case 'AUDIO': + return AudioAttachment.fromMap(map); + case 'FILE': + return FileAttachment.fromMap(map); + case 'STICKER': + return StickerAttachment.fromMap(map); + case 'CONTACT': + return ContactAttachment.fromMap(map); + case 'LOCATION': + return LocationAttachment.fromMap(map); + case 'CONTROL': + return ControlAttachment.fromMap(map); + default: + return UnknownAttachment(map); + } + } + + Map toMap(); +} + +class PhotoAttachment extends MessageAttachment { + final int? photoId; + final String? photoToken; + final int? width; + final int? height; + final int? size; + + const PhotoAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.photoId, + this.photoToken, + this.width, + this.height, + this.size, + }) : super(type: AttachmentType.photo); + + factory PhotoAttachment.fromMap(Map map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.from(previewRaw); + final base64 = String.fromCharCodes(bytes); + previewStr = 'data:image/webp;base64,$base64'; + } catch (_) {} + } + + return PhotoAttachment( + previewData: previewStr, + baseUrl: map['baseUrl'] as String?, + photoId: map['photoId'] as int?, + photoToken: map['photoToken'] as String?, + width: map['width'] as int?, + height: map['height'] as int?, + size: map['size'] as int?, + ); + } + + @override + Map toMap() => { + '_type': 'PHOTO', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'photoId': photoId, + 'photoToken': photoToken, + 'width': width, + 'height': height, + 'size': size, + }; +} + +class VideoAttachment extends MessageAttachment { + final int? videoId; + final String? videoToken; + final int? width; + final int? height; + final int? duration; + final int? size; + + const VideoAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.videoId, + this.videoToken, + this.width, + this.height, + this.duration, + this.size, + }) : super(type: AttachmentType.video); + + factory VideoAttachment.fromMap(Map map) { + return VideoAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + videoId: map['videoId'] as int?, + videoToken: map['videoToken'] as String?, + width: map['width'] as int?, + height: map['height'] as int?, + duration: map['duration'] as int?, + size: map['size'] as int?, + ); + } + + @override + Map toMap() => { + '_type': 'VIDEO', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'videoId': videoId, + 'videoToken': videoToken, + 'width': width, + 'height': height, + 'duration': duration, + 'size': size, + }; +} + +class AudioAttachment extends MessageAttachment { + final int? audioId; + final String? audioToken; + final int? duration; + final int? size; + final String? waveform; + + const AudioAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.audioId, + this.audioToken, + this.duration, + this.size, + this.waveform, + }) : super(type: AttachmentType.audio); + + factory AudioAttachment.fromMap(Map map) { + return AudioAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + audioId: map['audioId'] as int?, + audioToken: map['audioToken'] as String?, + duration: map['duration'] as int?, + size: map['size'] as int?, + waveform: map['waveform'] as String?, + ); + } + + @override + Map toMap() => { + '_type': 'AUDIO', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'audioId': audioId, + 'audioToken': audioToken, + 'duration': duration, + 'size': size, + 'waveform': waveform, + }; +} + +class FileAttachment extends MessageAttachment { + final int? fileId; + final String? fileToken; + final String? name; + final int? size; + + const FileAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.fileId, + this.fileToken, + this.name, + this.size, + }) : super(type: AttachmentType.file); + + factory FileAttachment.fromMap(Map map) { + return FileAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + fileId: map['fileId'] as int?, + fileToken: map['fileToken'] as String?, + name: map['name'] as String?, + size: map['size'] as int?, + ); + } + + @override + Map toMap() => { + '_type': 'FILE', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'fileId': fileId, + 'fileToken': fileToken, + 'name': name, + 'size': size, + }; +} + +class StickerAttachment extends MessageAttachment { + final String? stickerId; + final String? stickerPackId; + final int? width; + final int? height; + + const StickerAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.stickerId, + this.stickerPackId, + this.width, + this.height, + }) : super(type: AttachmentType.sticker); + + factory StickerAttachment.fromMap(Map map) { + return StickerAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + stickerId: map['stickerId'] as String?, + stickerPackId: map['stickerPackId'] as String?, + width: map['width'] as int?, + height: map['height'] as int?, + ); + } + + @override + Map toMap() => { + '_type': 'STICKER', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'stickerId': stickerId, + 'stickerPackId': stickerPackId, + 'width': width, + 'height': height, + }; +} + +class ContactAttachment extends MessageAttachment { + final String? userId; + final String? firstName; + final String? lastName; + final String? phoneNumber; + + const ContactAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.userId, + this.firstName, + this.lastName, + this.phoneNumber, + }) : super(type: AttachmentType.contact); + + factory ContactAttachment.fromMap(Map map) { + return ContactAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + userId: map['userId'] as String?, + firstName: map['firstName'] as String?, + lastName: map['lastName'] as String?, + phoneNumber: map['phoneNumber'] as String?, + ); + } + + @override + Map toMap() => { + '_type': 'CONTACT', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'userId': userId, + 'firstName': firstName, + 'lastName': lastName, + 'phoneNumber': phoneNumber, + }; +} + +class LocationAttachment extends MessageAttachment { + final double? latitude; + final double? longitude; + final String? title; + final String? address; + + const LocationAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.latitude, + this.longitude, + this.title, + this.address, + }) : super(type: AttachmentType.location); + + factory LocationAttachment.fromMap(Map map) { + return LocationAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + latitude: (map['latitude'] as num?)?.toDouble(), + longitude: (map['longitude'] as num?)?.toDouble(), + title: map['title'] as String?, + address: map['address'] as String?, + ); + } + + @override + Map toMap() => { + '_type': 'LOCATION', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'latitude': latitude, + 'longitude': longitude, + 'title': title, + 'address': address, + }; +} + +class ControlAttachment extends MessageAttachment { + final String? event; + final String? title; + final List? userIds; + + const ControlAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.event, + this.title, + this.userIds, + }) : super(type: AttachmentType.control); + + factory ControlAttachment.fromMap(Map map) { + return ControlAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + event: map['event'] as String?, + title: map['title'] as String?, + userIds: (map['userIds'] as List?)?.cast(), + ); + } + + @override + Map toMap() => { + '_type': 'CONTROL', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'event': event, + 'title': title, + 'userIds': userIds, + }; +} + +class UnknownAttachment extends MessageAttachment { + final Map rawData; + + const UnknownAttachment(this.rawData) : super(type: AttachmentType.photo); + + @override + Map toMap() => rawData; +} From c132c2cca5e579ea032f5d4405a24b8738ff64ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sun, 5 Apr 2026 21:37:15 +0700 Subject: [PATCH 047/323] =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=BE=D0=BC=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B4=D0=BE=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/message_bubble.dart | 40 +++++++++++++----------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index ba7b4a1..e6b213a 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -737,12 +737,18 @@ class MessageBubble extends StatelessWidget { Widget _buildFileAttachment(BuildContext ctx, MessageAttachment file) { final cs = Theme.of(ctx).colorScheme; final isDark = cs.brightness == Brightness.dark; + final name = (file as dynamic).name as String? ?? 'File'; + final size = (file as dynamic).size as int? ?? 0; + final sizeStr = _formatFileSize(size); final textColor = isMe ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - final name = (file as dynamic).name as String? ?? 'File'; + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); - return Column( + return Row( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Container( @@ -752,12 +758,7 @@ class MessageBubble extends StatelessWidget { color: isMe ? Colors.white.withValues(alpha: 0.15) : (isDark ? cs.surface : const Color(0xFFFFFFFF)), - borderRadius: BorderRadius.circular(photoBorderRadius), - border: Border.all( - color: isMe - ? Colors.white.withValues(alpha: 0.3) - : cs.outlineVariant.withValues(alpha: 0.5), - ), + borderRadius: BorderRadius.circular(12), ), child: Row( children: [ @@ -771,15 +772,16 @@ class MessageBubble extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: Icon( - Symbols.description, + Symbols.file_download, color: isMe ? Colors.white : cs.primary, - size: 20, + size: 22, ), ), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Text( name, @@ -788,16 +790,13 @@ class MessageBubble extends StatelessWidget { fontSize: 14, fontWeight: FontWeight.w500, ), - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( - 'Tap to download', - style: TextStyle( - color: textColor.withValues(alpha: 0.6), - fontSize: 12, - ), + 'Скачать • $sizeStr', + style: TextStyle(color: subtitleColor, fontSize: 12), ), ], ), @@ -805,12 +804,17 @@ class MessageBubble extends StatelessWidget { ], ), ), - const SizedBox(height: 6), _buildMeta(ctx), ], ); } + String _formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} МБ'; + } + Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) { final preview = (sticker as dynamic).previewData as String? ?? ''; From f4bb93b39fda82d9ffebc3db074363544c27fec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sun, 5 Apr 2026 23:39:04 +0700 Subject: [PATCH 048/323] =?UTF-8?q?=D1=8F=20=D1=83=D0=BC=D0=B5=D1=80=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D0=B0=20=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D0=BB?= =?UTF-8?q?=20=D1=84=D1=80=D0=BE=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/message_bubble.dart | 150 ++++++++++++----------- lib/models/attachment.dart | 56 ++++++++- 2 files changed, 131 insertions(+), 75 deletions(-) diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index e6b213a..c3a5b62 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -21,9 +21,6 @@ class MessageBubble extends StatelessWidget { return false; final hasPhoto = message.attachments!.any((a) => a is PhotoAttachment); final hasCaption = message.text != null && message.text!.isNotEmpty; - debugPrint( - 'DEBUG _hasPhotoWithCaption: hasPhoto=$hasPhoto, hasCaption=$hasCaption, text="${message.text}", shape=$shape', - ); return hasPhoto && hasCaption; } @@ -81,6 +78,7 @@ class MessageBubble extends StatelessWidget { MessageType get contentType { if (message.attachments != null && message.attachments!.isNotEmpty) { final first = message.attachments!.first; + if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; return MessageType.attachment; } @@ -251,16 +249,7 @@ class MessageBubble extends StatelessWidget { return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); } case MessageType.attachment: - switch (shape) { - case BubbleShape.groupedMiddle: - return const EdgeInsets.all(0); - case BubbleShape.singleTop: - return const EdgeInsets.all(0); - case BubbleShape.singleBottom: - return const EdgeInsets.all(0); - case BubbleShape.singleMiddle: - return const EdgeInsets.all(0); - } + return const EdgeInsets.all(0); case MessageType.voice: switch (shape) { case BubbleShape.groupedMiddle: @@ -395,7 +384,12 @@ class MessageBubble extends StatelessWidget { } if (count == 1) { - return IntrinsicWidth( + final photo = photos[0]; + final pw = photo.width?.toDouble() ?? 200; + final photoWidth = pw.clamp(photoMinSize, photoMaxSize); + + return SizedBox( + width: photoWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -410,7 +404,7 @@ class MessageBubble extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded(child: _buildCaption(ctx)), + Flexible(child: _buildCaption(ctx)), _buildMeta(ctx), ], ), @@ -744,68 +738,82 @@ class MessageBubble extends StatelessWidget { ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); final subtitleColor = isMe - ? Colors.white.withValues(alpha: 0.7) + ? Colors.white.withValues(alpha: 0.65) : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Container( - width: 220, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isMe - ? Colors.white.withValues(alpha: 0.15) - : (isDark ? cs.surface : const Color(0xFFFFFFFF)), - borderRadius: BorderRadius.circular(12), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: isMe + ? Colors.white.withValues(alpha: 0.2) + : cs.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Symbols.description, + color: isMe ? Colors.white : cs.primary, + size: 20, + ), ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: isMe - ? Colors.white.withValues(alpha: 0.2) - : cs.primaryContainer, - borderRadius: BorderRadius.circular(8), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - child: Icon( - Symbols.file_download, - color: isMe ? Colors.white : cs.primary, - size: 22, + const SizedBox(height: 2), + Text( + sizeStr, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - name, - style: TextStyle( - color: textColor, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - 'Скачать • $sizeStr', - style: TextStyle(color: subtitleColor, fontSize: 12), - ), - ], - ), - ), - ], + ], + ), ), - ), - _buildMeta(ctx), - ], + const SizedBox(width: 12), + GestureDetector( + onTap: () {}, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? Colors.white.withValues(alpha: 0.15) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE5E5EA)), + shape: BoxShape.circle, + ), + child: Icon( + Symbols.download, + color: isMe ? Colors.white : cs.primary, + size: 18, + ), + ), + ), + ], + ), ); } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 2270210..dbaf79e 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -41,6 +41,10 @@ abstract class MessageAttachment { return LocationAttachment.fromMap(map); case 'CONTROL': return ControlAttachment.fromMap(map); + case 'SHARE': + return FileAttachment.fromMap(map); + case 'INLINE_KEYBOARD': + return UnknownAttachment(map); default: return UnknownAttachment(map); } @@ -125,8 +129,19 @@ class VideoAttachment extends MessageAttachment { }) : super(type: AttachmentType.video); factory VideoAttachment.fromMap(Map map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.from(previewRaw); + previewStr = String.fromCharCodes(bytes); + } catch (_) {} + } + return VideoAttachment( - previewData: map['previewData'] as String?, + previewData: previewStr, baseUrl: map['baseUrl'] as String?, videoId: map['videoId'] as int?, videoToken: map['videoToken'] as String?, @@ -170,8 +185,19 @@ class AudioAttachment extends MessageAttachment { }) : super(type: AttachmentType.audio); factory AudioAttachment.fromMap(Map map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.from(previewRaw); + previewStr = String.fromCharCodes(bytes); + } catch (_) {} + } + return AudioAttachment( - previewData: map['previewData'] as String?, + previewData: previewStr, baseUrl: map['baseUrl'] as String?, audioId: map['audioId'] as int?, audioToken: map['audioToken'] as String?, @@ -211,8 +237,19 @@ class FileAttachment extends MessageAttachment { }) : super(type: AttachmentType.file); factory FileAttachment.fromMap(Map map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.from(previewRaw); + previewStr = String.fromCharCodes(bytes); + } catch (_) {} + } + return FileAttachment( - previewData: map['previewData'] as String?, + previewData: previewStr, baseUrl: map['baseUrl'] as String?, fileId: map['fileId'] as int?, fileToken: map['fileToken'] as String?, @@ -250,8 +287,19 @@ class StickerAttachment extends MessageAttachment { }) : super(type: AttachmentType.sticker); factory StickerAttachment.fromMap(Map map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.from(previewRaw); + previewStr = String.fromCharCodes(bytes); + } catch (_) {} + } + return StickerAttachment( - previewData: map['previewData'] as String?, + previewData: previewStr, baseUrl: map['baseUrl'] as String?, stickerId: map['stickerId'] as String?, stickerPackId: map['stickerPackId'] as String?, From a7d2551dcf72c354010badcce7b6649dca77b9c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Mon, 6 Apr 2026 00:12:42 +0700 Subject: [PATCH 049/323] =?UTF-8?q?=D0=BD=D0=B5=D0=BA=D0=B0=D1=8F=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=BA=D0=BE=D1=81=D0=B0=D1=8F=20=D0=BA=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D1=8F=20=D0=BD=D0=BE=20=D0=BF=D0=BE=D1=85?= =?UTF-8?q?=D1=83=D0=B9=20=D1=8F=20=D1=81=D0=BF=D0=B0=D1=82=D1=8C=20=D1=85?= =?UTF-8?q?=D0=BE=D1=87=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 483cd94..61a2dbb 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -180,8 +180,22 @@ class MessagesModule { ); } - Future sendMessage(int accountId, int chatId, String text) async { - final payload = {'chatId': chatId, 'text': text}; + Future sendMessage( + int accountId, + int chatId, + String text, { + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'text': text, + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'elements': [], + 'attaches': [], + }, + 'notify': notify, + }; await _api.sendRequest(Opcode.msgSend, payload); } From 19dfd4c82eb4dbd802d1d6f3a40d1f5ff573deb8 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 5 Apr 2026 20:59:30 +0300 Subject: [PATCH 050/323] feat(settings): add app version display in settings tab and update dependencies --- .../screens/profile/settings_tab.dart | 29 ++++++++++++++++- pubspec.lock | 32 ++++++++++++++----- pubspec.yaml | 1 + 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 3dc116c..4892345 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,6 +1,6 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; import 'devices_screen.dart'; import 'security_screen.dart'; @@ -16,11 +16,13 @@ class SettingsTab extends StatefulWidget { class _SettingsTabState extends State { ProfileData? _profile; bool _isPhoneVisible = false; + String? _appVersionLabel; @override void initState() { super.initState(); _loadProfile(); + _loadAppVersion(); } Future _loadProfile() async { @@ -28,6 +30,14 @@ class _SettingsTabState extends State { if (mounted) setState(() => _profile = p); } + Future _loadAppVersion() async { + final info = await PackageInfo.fromPlatform(); + if (!mounted) return; + setState(() { + _appVersionLabel = 'Версия ${info.version} (${info.buildNumber})'; + }); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -117,6 +127,23 @@ class _SettingsTabState extends State { ), ), ), + if (_appVersionLabel != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 28, 16, 12), + child: Center( + child: Text( + _appVersionLabel!, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.75), + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 120)), ], ), diff --git a/pubspec.lock b/pubspec.lock index ca2281c..a6437bc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: @@ -365,6 +365,22 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -614,10 +630,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2dd41b7..d849066 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: material_symbols_icons: ^4.2906.0 dynamic_color: ^1.8.1 shared_preferences: ^2.5.4 + package_info_plus: ^9.0.1 dev_dependencies: flutter_test: From 21ead2d6aa614a320f44e9d789bd0a0e152cf33a Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 5 Apr 2026 21:14:05 +0300 Subject: [PATCH 051/323] feat(settings): implement debug menu toggle in settings tab with version label tap detection --- .../screens/profile/debug_menu_screen.dart | 56 +++++++++ .../screens/profile/settings_tab.dart | 113 ++++++++++++++++-- 2 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 lib/frontend/screens/profile/debug_menu_screen.dart diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart new file mode 100644 index 0000000..dc83d3f --- /dev/null +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class DebugMenuScreen extends StatelessWidget { + const DebugMenuScreen({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Для разработчиков', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + ], + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 120)), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 4892345..4a37280 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -17,6 +20,9 @@ class _SettingsTabState extends State { ProfileData? _profile; bool _isPhoneVisible = false; String? _appVersionLabel; + bool _debugMenuVisible = false; + int _versionSecretTapCount = 0; + Timer? _versionSecretTapResetTimer; @override void initState() { @@ -25,6 +31,31 @@ class _SettingsTabState extends State { _loadAppVersion(); } + @override + void dispose() { + _versionSecretTapResetTimer?.cancel(); + super.dispose(); + } + + void _scheduleVersionSecretTapReset() { + _versionSecretTapResetTimer?.cancel(); + _versionSecretTapResetTimer = Timer(const Duration(seconds: 2), () { + if (mounted) setState(() => _versionSecretTapCount = 0); + }); + } + + void _onVersionLabelTap() { + _scheduleVersionSecretTapReset(); + setState(() { + _versionSecretTapCount++; + if (_versionSecretTapCount >= 7) { + _versionSecretTapCount = 0; + _versionSecretTapResetTimer?.cancel(); + _debugMenuVisible = !_debugMenuVisible; + } + }); + } + Future _loadProfile() async { final p = await AppDatabase.loadActiveProfile(); if (mounted) setState(() => _profile = p); @@ -127,18 +158,86 @@ class _SettingsTabState extends State { ), ), ), + SliverToBoxAdapter( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 340), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: animation.value.clamp(0.0, 1.0), + child: FadeTransition( + opacity: animation, + child: child, + ), + ), + ); + }, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.topCenter, + clipBehavior: Clip.none, + children: [ + ...previousChildren, + ?currentChild, + ], + ); + }, + child: _debugMenuVisible + ? KeyedSubtree( + key: const ValueKey('developers_settings_row'), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSection( + context, + cs, + items: [ + _SettingsItem( + icon: Symbols.construction, + label: 'Для разработчиков', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const DebugMenuScreen(), + ), + ); + }, + ), + ], + ), + ), + ) + : const SizedBox.shrink( + key: ValueKey('developers_settings_hidden'), + ), + ), + ), if (_appVersionLabel != null) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 28, 16, 12), child: Center( - child: Text( - _appVersionLabel!, - textAlign: TextAlign.center, - style: TextStyle( - color: cs.onSurfaceVariant.withValues(alpha: 0.75), - fontSize: 13, - fontWeight: FontWeight.w400, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onVersionLabelTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + child: Text( + _appVersionLabel!, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.75), + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), ), ), ), From 4d4958a25f98fde22e0413a6f8973ac87e7d042b Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 5 Apr 2026 21:36:21 +0300 Subject: [PATCH 052/323] =?UTF-8?q?feat(settings):=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F,=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D1=82=D0=B0=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D0=BC=D1=8B=D0=B9=20FPS-=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/debug/fps_overlay_layer.dart | 145 ++++++++++++++++++ .../screens/profile/debug_menu_screen.dart | 68 ++++++++ lib/main.dart | 47 +++++- 3 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 lib/frontend/debug/fps_overlay_layer.dart diff --git a/lib/frontend/debug/fps_overlay_layer.dart b/lib/frontend/debug/fps_overlay_layer.dart new file mode 100644 index 0000000..c9af7b0 --- /dev/null +++ b/lib/frontend/debug/fps_overlay_layer.dart @@ -0,0 +1,145 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +class FpsOverlayLayer extends StatefulWidget { + const FpsOverlayLayer({super.key}); + + @override + State createState() => _FpsOverlayLayerState(); +} + +class _FpsOverlayLayerState extends State { + static const int _maxSamples = 90; + static const int _minUiRefreshMs = 160; + static const double _initialWidthGuess = 96; + static const double _initialHeightGuess = 36; + + final List _frameMicros = []; + final GlobalKey _badgeKey = GlobalKey(); + double _fps = 0; + DateTime _lastUiUpdate = DateTime.fromMillisecondsSinceEpoch(0); + double? _left; + double? _top; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addTimingsCallback(_onTimings); + } + + @override + void dispose() { + WidgetsBinding.instance.removeTimingsCallback(_onTimings); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_left != null && _top != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(_clampPositionToScreen); + } + }); + } + } + + void _ensureInitialPosition() { + if (_left != null) return; + final mq = MediaQuery.of(context); + final w = mq.size.width; + _left = w - _initialWidthGuess - 8; + _top = mq.padding.top + 8; + } + + void _clampPositionToScreen() { + if (_left == null || _top == null) return; + final mq = MediaQuery.of(context); + final screen = mq.size; + final topMin = mq.padding.top; + final bottomMax = screen.height - mq.padding.bottom; + + final box = _badgeKey.currentContext?.findRenderObject() as RenderBox?; + final bw = box?.hasSize == true + ? box!.size.width + : _initialWidthGuess; + final bh = box?.hasSize == true + ? box!.size.height + : _initialHeightGuess; + + _left = _left!.clamp(0.0, math.max(0.0, screen.width - bw)); + _top = _top!.clamp(topMin, math.max(topMin, bottomMax - bh)); + } + + void _onTimings(List timings) { + for (final t in timings) { + final us = t.totalSpan.inMicroseconds; + if (us <= 0) continue; + _frameMicros.add(us); + while (_frameMicros.length > _maxSamples) { + _frameMicros.removeAt(0); + } + } + final now = DateTime.now(); + if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) { + return; + } + _lastUiUpdate = now; + if (!mounted || _frameMicros.isEmpty) return; + final sum = _frameMicros.fold(0, (a, b) => a + b); + final avg = sum / _frameMicros.length; + final fps = avg > 0 ? (1000000.0 / avg).clamp(0.0, 999.0) : 0.0; + setState(() => _fps = fps); + } + + @override + Widget build(BuildContext context) { + _ensureInitialPosition(); + _clampPositionToScreen(); + + return Positioned( + left: _left, + top: _top, + child: MouseRegion( + cursor: SystemMouseCursors.move, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (details) { + setState(() { + _left = _left! + details.delta.dx; + _top = _top! + details.delta.dy; + _clampPositionToScreen(); + }); + }, + child: Material( + key: _badgeKey, + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xCC000000), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${_fps.round()} FPS', + style: TextStyle( + color: _fps >= 55 + ? const Color(0xFFB8F5C6) + : _fps >= 30 + ? const Color(0xFFFFE082) + : const Color(0xFFFFAB91), + fontSize: 13, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index dc83d3f..5f7de26 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart'; + class DebugMenuScreen extends StatelessWidget { const DebugMenuScreen({super.key}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final appState = KometApp.stateOf(context); return Scaffold( backgroundColor: cs.surface, @@ -47,6 +50,71 @@ class DebugMenuScreen extends StatelessWidget { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: appState == null + ? const SizedBox.shrink() + : ValueListenableBuilder( + valueListenable: appState.fpsOverlayEnabled, + builder: (context, fpsOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.speed, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Оверлей FPS', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Показ текущего фреймрейта поверх интерфейса', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: fpsOn, + onChanged: (v) { + appState.setFpsOverlayEnabled(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 120)), ], ), diff --git a/lib/main.dart b/lib/main.dart index 691248e..82ae9b6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; import 'core/protocol/packet.dart'; +import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; import 'frontend/screens/chats/chat_list_screen.dart'; import 'frontend/widgets/custom_notification.dart'; @@ -35,13 +36,25 @@ void main() async { await AppDatabase.init(); await api.connect(); final initialLocale = await _loadInitialLocale(); - runApp(KometApp(initialLocale: initialLocale)); + final prefs = await SharedPreferences.getInstance(); + final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; + runApp( + KometApp( + initialLocale: initialLocale, + initialFpsOverlay: initialFpsOverlay, + ), + ); } class KometApp extends StatefulWidget { - const KometApp({super.key, required this.initialLocale}); + const KometApp({ + super.key, + required this.initialLocale, + this.initialFpsOverlay = false, + }); final Locale initialLocale; + final bool initialFpsOverlay; static final navigatorKey = GlobalKey(); static KometAppState? stateOf(BuildContext context) { @@ -57,6 +70,8 @@ class KometAppState extends State { late Locale _locale; bool _isLoggingOut = false; + late final ValueNotifier fpsOverlayEnabled = + ValueNotifier(widget.initialFpsOverlay); @override void initState() { @@ -95,6 +110,19 @@ class KometAppState extends State { }); } + @override + void dispose() { + fpsOverlayEnabled.dispose(); + super.dispose(); + } + + Future setFpsOverlayEnabled(bool value) async { + if (fpsOverlayEnabled.value == value) return; + fpsOverlayEnabled.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('dev_fps_overlay', value); + } + Future applyLocale(Locale locale) async { if (!AppLocalizations.supportedLocales.any( (l) => l.languageCode == locale.languageCode, @@ -184,6 +212,21 @@ class KometAppState extends State { ), ), navigatorKey: KometApp.navigatorKey, + builder: (context, child) { + return ValueListenableBuilder( + valueListenable: fpsOverlayEnabled, + builder: (context, fpsOn, _) { + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + child ?? const SizedBox.shrink(), + if (fpsOn) const FpsOverlayLayer(), + ], + ); + }, + ); + }, home: const _StartupScreen(), ); }, From 8a9b229082b14c9ca18f0604f1953fd6c1cd7121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Mon, 6 Apr 2026 18:24:45 +0700 Subject: [PATCH 053/323] olo --- lib/frontend/screens/chats/chat_screen.dart | 2 +- pubspec.lock | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 3a611ab..2944231 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -168,7 +168,7 @@ class _ChatScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( - _scrollController.position.maxScrollExtent, + _scrollController.position.minScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); diff --git a/pubspec.lock b/pubspec.lock index a6437bc..5a852a3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -630,10 +630,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: From 3dd8b39e235dceb62a0c5950e14b2a90dcd1dd45 Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 6 Apr 2026 15:06:12 +0300 Subject: [PATCH 054/323] feat(devices): QR approve login for web and desktop MAX --- android/app/src/main/AndroidManifest.xml | 1 + android/gradle.properties | 1 + ios/Runner/Info.plist | 2 + lib/backend/modules/account.dart | 16 ++ .../screens/profile/devices_screen.dart | 203 ++++++++++++++++++ .../screens/profile/web_qr_scan_screen.dart | 117 ++++++++++ pubspec.lock | 8 + pubspec.yaml | 1 + 8 files changed, 349 insertions(+) create mode 100644 lib/frontend/screens/profile/web_qr_scan_screen.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a503dd6..b2c538d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + UIApplicationSupportsIndirectInputEvents + NSCameraUsageDescription + Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере. diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 67aeb2d..bb4f03d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -288,6 +288,22 @@ class AccountModule { _checkPacketError(packet, 'terminateOtherSessions'); } + Future authorizeWebQrLogin(String qrLink) async { + _ensureOnline(); + final link = qrLink.trim(); + if (link.isEmpty) { + throw ArgumentError('Пустая ссылка из QR'); + } + + await _api.sendRequest(Opcode.ping, {'interactive': true}); + await _api.sendRequest(Opcode.sessionsInfo, {}); + await Future.delayed(const Duration(milliseconds: 300)); + final packet = await _api.sendRequest(Opcode.authQrApprove, { + 'qrLink': link, + }); + _checkPacketError(packet, 'authorizeWebQrLogin'); + } + Future switchAccount(int accountId) async { final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 40ab60d..8030921 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -1,12 +1,15 @@ import 'dart:io'; import 'dart:convert'; import 'dart:math'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show SessionInfo; import '../../widgets/custom_notification.dart'; +import 'web_qr_scan_screen.dart'; class DevicesScreen extends StatefulWidget { const DevicesScreen({super.key}); @@ -57,6 +60,188 @@ class _DevicesScreenState extends State } } + Future _showPasteQrDialog() async { + final tec = TextEditingController(); + try { + return await showDialog( + context: context, + builder: (dialogContext) { + final cs = Theme.of(dialogContext).colorScheme; + return AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + title: Text( + 'Ссылка из QR', + style: GoogleFonts.outfit( + fontWeight: FontWeight.w600, + fontSize: 18, + color: cs.onSurface, + ), + ), + content: TextField( + controller: tec, + decoration: const InputDecoration( + hintText: 'Вставьте содержимое QR-кода', + ), + autofocus: true, + maxLines: 4, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text( + 'Отмена', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ), + FilledButton( + onPressed: () { + final v = tec.text.trim(); + Navigator.pop(dialogContext, v.isEmpty ? null : v); + }, + child: const Text('Подтвердить'), + ), + ], + ); + }, + ); + } finally { + tec.dispose(); + } + } + + Future _confirmQrWebLoginSheet() async { + final agreed = await showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + final cs = Theme.of(sheetContext).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + Text( + 'Вход по QR', + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + 'Вы точно хотите войти в аккаунт через веб или приложение MAX на компьютере?', + style: TextStyle( + fontSize: 15, + height: 1.35, + color: cs.onSurfaceVariant, + ), + ), + const SizedBox(height: 28), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => + Navigator.of(sheetContext).pop(false), + child: Text( + 'Отмена', + style: TextStyle(color: cs.onSurface), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () => + Navigator.of(sheetContext).pop(true), + child: const Text('Войти'), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + return agreed ?? false; + } + + Future _startWebQrAuth() async { + final canScan = !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); + + final String? qr; + if (canScan) { + qr = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const WebQrScanScreen()), + ); + } else { + qr = await _showPasteQrDialog(); + } + + if (!mounted) return; + if (qr == null || qr.trim().isEmpty) return; + + final confirmed = await _confirmQrWebLoginSheet(); + if (!mounted) return; + if (!confirmed) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + final cs = Theme.of(ctx).colorScheme; + return PopScope( + canPop: false, + child: Center( + child: Card( + color: cs.surfaceContainerHigh, + child: const Padding( + padding: EdgeInsets.all(28), + child: CircularProgressIndicator(), + ), + ), + ), + ); + }, + ); + + try { + await accountModule.authorizeWebQrLogin(qr.trim()); + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + showCustomNotification(context, 'Вход по QR подтверждён'); + _loadSessions(); + } + } catch (e) { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + showCustomNotification(context, 'Ошибка: $e'); + } + } + } + Future _terminateOthers() async { try { await accountModule.terminateOtherSessions(); @@ -229,6 +414,24 @@ class _DevicesScreenState extends State height: 1.3, ), ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _startWebQrAuth, + icon: const Icon(Symbols.qr_code_scanner, size: 22), + label: Text( + 'Сканировать QR', + style: GoogleFonts.outfit( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + ), + ), ], ), ), diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart new file mode 100644 index 0000000..c512811 --- /dev/null +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class WebQrScanScreen extends StatefulWidget { + const WebQrScanScreen({super.key}); + + @override + State createState() => _WebQrScanScreenState(); +} + +class _WebQrScanScreenState extends State { + final MobileScannerController _controller = MobileScannerController(); + bool _handled = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_handled) return; + final barcodes = capture.barcodes; + if (barcodes.isEmpty) return; + final raw = barcodes.first.rawValue; + if (raw == null || raw.isEmpty) return; + _handled = true; + _controller.stop(); + if (mounted) Navigator.of(context).pop(raw); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Symbols.chevron_left, size: 28), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'QR для веба и ПК', + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + centerTitle: true, + actions: [ + IconButton( + icon: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, _) { + final on = state.torchState == TorchState.on; + return Icon( + on ? Symbols.flash_on : Symbols.flash_off, + color: Colors.white, + ); + }, + ), + onPressed: () => _controller.toggleTorch(), + ), + ], + ), + body: Stack( + fit: StackFit.expand, + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + errorBuilder: (context, error) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + error.errorDetails?.message ?? 'Камера недоступна', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70, fontSize: 15), + ), + ), + ); + }, + ), + Positioned( + left: 0, + right: 0, + bottom: 48, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + 'Наведите камеру на QR-код на экране компьютера', + textAlign: TextAlign.center, + style: GoogleFonts.outfit( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Colors.white, + shadows: const [ + Shadow( + blurRadius: 8, + color: Colors.black54, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5a852a3..62a257f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -341,6 +341,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce + url: "https://pub.dev" + source: hosted + version: "7.2.0" msgpack_dart: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d849066..0341c9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: dynamic_color: ^1.8.1 shared_preferences: ^2.5.4 package_info_plus: ^9.0.1 + mobile_scanner: ^7.2.0 dev_dependencies: flutter_test: From 9a1dc8e82b2ad6ce49aba1b8993f95f157a5bfc3 Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 6 Apr 2026 15:43:33 +0300 Subject: [PATCH 055/323] feat(qr): rounded finder overlay, dim mask, green lock, settle delay before pop --- .../screens/profile/web_qr_scan_screen.dart | 436 ++++++++++++++++-- pubspec.lock | 16 +- 2 files changed, 404 insertions(+), 48 deletions(-) diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart index c512811..a859b5a 100644 --- a/lib/frontend/screens/profile/web_qr_scan_screen.dart +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -1,4 +1,8 @@ +import 'dart:async'; +import 'dart:math' as math; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; @@ -12,23 +16,42 @@ class WebQrScanScreen extends StatefulWidget { class _WebQrScanScreenState extends State { final MobileScannerController _controller = MobileScannerController(); + static const _settleAfterDetect = Duration(milliseconds: 720); + bool _handled = false; + String? _armedRaw; + Timer? _completeTimer; @override void dispose() { + _completeTimer?.cancel(); _controller.dispose(); super.dispose(); } + void _completeScan() { + if (!mounted || _handled) return; + final value = _armedRaw; + if (value == null || value.isEmpty) return; + _handled = true; + _completeTimer?.cancel(); + _completeTimer = null; + unawaited(_controller.stop()); + if (mounted) Navigator.of(context).pop(value); + } + void _onDetect(BarcodeCapture capture) { if (_handled) return; final barcodes = capture.barcodes; if (barcodes.isEmpty) return; final raw = barcodes.first.rawValue; if (raw == null || raw.isEmpty) return; - _handled = true; - _controller.stop(); - if (mounted) Navigator.of(context).pop(raw); + + if (_armedRaw != raw) { + _armedRaw = raw; + _completeTimer?.cancel(); + _completeTimer = Timer(_settleAfterDetect, _completeScan); + } } @override @@ -68,50 +91,383 @@ class _WebQrScanScreenState extends State { ), ], ), - body: Stack( - fit: StackFit.expand, - children: [ - MobileScanner( - controller: _controller, - onDetect: _onDetect, - errorBuilder: (context, error) { - return Center( + body: LayoutBuilder( + builder: (context, constraints) { + final layoutSize = Size(constraints.maxWidth, constraints.maxHeight); + return Stack( + fit: StackFit.expand, + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + fit: BoxFit.cover, + errorBuilder: (context, error) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + error.errorDetails?.message ?? 'Камера недоступна', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontSize: 15, + ), + ), + ), + ); + }, + ), + _TelegramStyleFinderOverlay( + layoutSize: layoutSize, + controller: _controller, + ), + Positioned( + left: 0, + right: 0, + bottom: 48, child: Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( - error.errorDetails?.message ?? 'Камера недоступна', + 'Наведите камеру на QR-код на экране компьютера', textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70, fontSize: 15), + style: GoogleFonts.outfit( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Colors.white, + shadows: const [ + Shadow( + blurRadius: 8, + color: Colors.black54, + ), + ], + ), ), ), - ); - }, - ), - Positioned( - left: 0, - right: 0, - bottom: 48, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text( - 'Наведите камеру на QR-код на экране компьютера', - textAlign: TextAlign.center, - style: GoogleFonts.outfit( - fontSize: 15, - fontWeight: FontWeight.w500, - color: Colors.white, - shadows: const [ - Shadow( - blurRadius: 8, - color: Colors.black54, - ), - ], - ), ), - ), - ), - ], + ], + ); + }, ), ); } } + +class _TelegramStyleFinderOverlay extends StatefulWidget { + const _TelegramStyleFinderOverlay({ + required this.layoutSize, + required this.controller, + }); + + final Size layoutSize; + final MobileScannerController controller; + + @override + State<_TelegramStyleFinderOverlay> createState() => + _TelegramStyleFinderOverlayState(); +} + +class _TelegramStyleFinderOverlayState extends State<_TelegramStyleFinderOverlay> + with SingleTickerProviderStateMixin { + static const _animDuration = Duration(milliseconds: 320); + static const _snapPx = 14.0; + static const _frameCornerRadius = 16.0; + static const _qrBoundsPadding = 14.0; + + late AnimationController _ac; + late CurvedAnimation _curved; + Rect _fromR = Rect.zero; + Rect _toR = Rect.zero; + StreamSubscription? _barcodeSub; + bool _qrInView = false; + + @override + void initState() { + super.initState(); + final d = _defaultFinderRect(widget.layoutSize); + _fromR = d; + _toR = d; + _ac = AnimationController(vsync: this, duration: _animDuration); + _curved = CurvedAnimation(parent: _ac, curve: Curves.easeOutCubic); + _ac.addListener(() => setState(() {})); + _barcodeSub = widget.controller.barcodes.listen(_onBarcodeFrame); + widget.controller.addListener(_onControllerUpdate); + } + + @override + void didUpdateWidget(covariant _TelegramStyleFinderOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.layoutSize != widget.layoutSize) { + final d = _defaultFinderRect(widget.layoutSize); + _fromR = d; + _toR = d; + _ac.value = 1.0; + } + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerUpdate); + unawaited(_barcodeSub?.cancel()); + _curved.dispose(); + _ac.dispose(); + super.dispose(); + } + + void _onControllerUpdate() { + final v = widget.controller.value; + if (!v.isInitialized || !v.isRunning || v.error != null) { + if (mounted) setState(() {}); + } + } + + void _onBarcodeFrame(BarcodeCapture cap) { + if (!mounted) return; + final v = widget.controller.value; + if (!v.isInitialized || !v.isRunning || v.error != null) return; + + final defaultRect = _defaultFinderRect(widget.layoutSize); + final mapped = _targetFinderRectFromCapture(cap, v); + final inView = mapped != null; + if (inView != _qrInView) { + setState(() => _qrInView = inView); + } + final next = mapped ?? defaultRect; + if (_rectClose(_toR, next) && _ac.isCompleted) { + return; + } + _animateTo(next); + } + + Rect _defaultFinderRect(Size s) { + final side = math.min(s.width, s.height) * 0.68; + final left = (s.width - side) / 2; + final top = (s.height - side) / 2 - s.height * 0.06; + return Rect.fromLTWH(left, top, side, side); + } + + bool _rectClose(Rect a, Rect b) { + return (a.left - b.left).abs() < _snapPx && + (a.top - b.top).abs() < _snapPx && + (a.width - b.width).abs() < _snapPx && + (a.height - b.height).abs() < _snapPx; + } + + Rect _interpolatedFinderRect() { + if (_fromR.isEmpty || _toR.isEmpty) { + return _defaultFinderRect(widget.layoutSize); + } + return Rect.lerp(_fromR, _toR, _curved.value)!; + } + + void _animateTo(Rect target) { + if (_rectClose(_toR, target) && _ac.isCompleted) { + return; + } + double t; + if (_ac.status == AnimationStatus.completed) { + t = 1.0; + } else if (_ac.status == AnimationStatus.dismissed) { + t = 0.0; + } else { + t = _curved.value; + } + _fromR = Rect.lerp(_fromR, _toR, t)!; + _toR = target; + _ac.forward(from: 0); + } + + Rect? _targetFinderRectFromCapture( + BarcodeCapture? cap, + MobileScannerState scannerState, + ) { + if (cap == null || cap.barcodes.isEmpty || widget.layoutSize.isEmpty) { + return null; + } + final b = cap.barcodes.first; + if (b.corners.length < 4) return null; + + final refSize = !cap.size.isEmpty + ? cap.size + : (!scannerState.size.isEmpty ? scannerState.size : Size.zero); + if (refSize.isEmpty) return null; + + final mapped = _mapBarcodeCornersToLayout( + b.corners.take(4).toList(), + refSize, + widget.layoutSize, + scannerState.deviceOrientation, + ); + if (mapped.length < 4) return null; + return _axisSquareAroundCorners( + mapped, + widget.layoutSize, + padding: _qrBoundsPadding, + ); + } + + static Rect _axisSquareAroundCorners( + List corners, + Size layout, { + required double padding, + }) { + var minX = double.infinity; + var maxX = double.negativeInfinity; + var minY = double.infinity; + var maxY = double.negativeInfinity; + for (final o in corners) { + minX = math.min(minX, o.dx); + maxX = math.max(maxX, o.dx); + minY = math.min(minY, o.dy); + maxY = math.max(maxY, o.dy); + } + final bw = maxX - minX + 2 * padding; + final bh = maxY - minY + 2 * padding; + final side = math.max(bw, bh); + final cx = (minX + maxX) / 2; + final cy = (minY + maxY) / 2; + var r = Rect.fromCenter(center: Offset(cx, cy), width: side, height: side); + r = r.intersect(Rect.fromLTWH(0, 0, layout.width, layout.height)); + if (r.isEmpty) { + return Rect.fromLTWH(0, 0, layout.shortestSide * 0.5, layout.shortestSide * 0.5); + } + return r; + } + + static RRect _finderRRect(Rect rect, double maxRadius) { + final r = math.min( + maxRadius, + math.min(rect.width, rect.height) * 0.14, + ); + return RRect.fromRectAndRadius(rect, Radius.circular(r)); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.controller, + builder: (context, state, _) { + if (!state.isInitialized || !state.isRunning || state.error != null) { + return const SizedBox.expand(); + } + + final finderRect = _interpolatedFinderRect(); + final rrect = _finderRRect(finderRect, _frameCornerRadius); + final frameColor = + _qrInView ? const Color(0xFF4ADE80) : Colors.white; + return IgnorePointer( + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + size: widget.layoutSize, + painter: _ScannerDimOutsideRRectPainter( + hole: rrect, + dimColor: Colors.black.withValues(alpha: 0.58), + ), + ), + CustomPaint( + size: widget.layoutSize, + painter: _FinderRoundedSquareStrokePainter( + rrect: rrect, + color: frameColor, + strokeWidth: 3.2, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _ScannerDimOutsideRRectPainter extends CustomPainter { + _ScannerDimOutsideRRectPainter({ + required this.hole, + required this.dimColor, + }); + + final RRect hole; + final Color dimColor; + + @override + void paint(Canvas canvas, Size size) { + final outer = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + final inner = Path()..addRRect(hole); + final mask = Path.combine(PathOperation.difference, outer, inner); + canvas.drawPath(mask, Paint()..color = dimColor); + } + + @override + bool shouldRepaint(covariant _ScannerDimOutsideRRectPainter oldDelegate) { + if (oldDelegate.dimColor != dimColor) return true; + return oldDelegate.hole != hole; + } +} + +class _FinderRoundedSquareStrokePainter extends CustomPainter { + _FinderRoundedSquareStrokePainter({ + required this.rrect, + required this.color, + required this.strokeWidth, + }); + + final RRect rrect; + final Color color; + final double strokeWidth; + + @override + void paint(Canvas canvas, Size size) { + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.45) + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + 2.5 + ..strokeCap = StrokeCap.round + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3); + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + canvas.drawRRect(rrect, shadowPaint); + canvas.drawRRect(rrect, paint); + } + + @override + bool shouldRepaint(covariant _FinderRoundedSquareStrokePainter oldDelegate) { + return oldDelegate.rrect != rrect || + oldDelegate.color != color || + oldDelegate.strokeWidth != strokeWidth; + } +} + +List _mapBarcodeCornersToLayout( + List barcodeCorners, + Size cameraPreviewSize, + Size layoutSize, + DeviceOrientation deviceOrientation, +) { + if (barcodeCorners.length < 4 || cameraPreviewSize.isEmpty) { + return []; + } + + final isLandscape = deviceOrientation == DeviceOrientation.landscapeLeft || + deviceOrientation == DeviceOrientation.landscapeRight; + final cam = isLandscape ? cameraPreviewSize.flipped : cameraPreviewSize; + + final wr = layoutSize.width / cam.width; + final hr = layoutSize.height / cam.height; + final ratio = math.max(wr, hr); + final hPad = (cam.width * ratio - layoutSize.width) / 2; + final vPad = (cam.height * ratio - layoutSize.height) / 2; + + return [ + for (final o in barcodeCorners) + Offset( + o.dx * ratio - hPad, + o.dy * ratio - vPad, + ), + ]; +} + diff --git a/pubspec.lock b/pubspec.lock index 62a257f..66aac21 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" timezone: dependency: "direct main" description: From c4623d9dc7644ecfb32c77b7f3d1e9989f9c478a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Mon, 6 Apr 2026 22:04:22 +0700 Subject: [PATCH 056/323] =?UTF-8?q?=D0=B1=D0=BB=D1=8F=20=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=20=D0=BC=D0=BD=D0=B5=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=83=20=D0=BC=D0=B5=D0=BD=D1=8F=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BC=D0=BF=D0=B1=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 476 ++++++ lib/core/storage/app_database.dart | 63 +- lib/core/transport/sender.dart | 2 - .../profile/password_entry_screen.dart | 1488 +++++++++++++++++ .../screens/profile/security_screen.dart | 898 +++++++++- pubspec.lock | 16 +- 6 files changed, 2843 insertions(+), 100 deletions(-) create mode 100644 lib/frontend/screens/profile/password_entry_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index bb4f03d..52fe39d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -9,6 +10,184 @@ import 'chats.dart'; import 'contacts.dart'; import 'folders.dart'; +class PrivacyConfig { + final String searchByPhone; + final String incomingCall; + final bool doubleTapReactionDisabled; + final bool safeModeNoPin; + final String? doubleTapReactionValue; + final String familyProtection; + final bool pushDetails; + final bool hidden; + final String chatsInvite; + final bool pushNewContacts; + final bool unsafeFiles; + final String inactiveTtl; + final bool showReadMark; + final bool altKeyboard; + final bool contentLevelAccess; + final String stickersSuggest; + final bool safeMode; + final bool audioTranscriptionEnabled; + final String hash; + + const PrivacyConfig({ + required this.searchByPhone, + required this.incomingCall, + required this.doubleTapReactionDisabled, + required this.safeModeNoPin, + this.doubleTapReactionValue, + required this.familyProtection, + required this.pushDetails, + required this.hidden, + required this.chatsInvite, + required this.pushNewContacts, + required this.unsafeFiles, + required this.inactiveTtl, + required this.showReadMark, + required this.altKeyboard, + required this.contentLevelAccess, + required this.stickersSuggest, + required this.safeMode, + required this.audioTranscriptionEnabled, + required this.hash, + }); + + factory PrivacyConfig.fromMap(Map map) { + return PrivacyConfig( + searchByPhone: map['SEARCH_BY_PHONE']?.toString() ?? 'ALL', + incomingCall: map['INCOMING_CALL']?.toString() ?? 'CONTACTS', + doubleTapReactionDisabled: map['DOUBLE_TAP_REACTION_DISABLED'] ?? false, + safeModeNoPin: map['SAFE_MODE_NO_PIN'] ?? false, + doubleTapReactionValue: map['DOUBLE_TAP_REACTION_VALUE']?.toString(), + familyProtection: map['FAMILY_PROTECTION']?.toString() ?? 'OFF', + pushDetails: map['PUSH_DETAILS'] ?? false, + hidden: map['HIDDEN'] ?? true, + chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS', + pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false, + unsafeFiles: map['UNSAFE_FILES'] ?? true, + inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M', + showReadMark: map['SHOW_READ_MARK'] ?? true, + altKeyboard: map['ALT_KEYBOARD'] ?? false, + contentLevelAccess: map['CONTENT_LEVEL_ACCESS'] ?? false, + stickersSuggest: map['STICKERS_SUGGEST']?.toString() ?? 'ON', + safeMode: map['SAFE_MODE'] ?? false, + audioTranscriptionEnabled: map['AUDIO_TRANSCRIPTION_ENABLED'] ?? true, + hash: map['hash']?.toString() ?? '', + ); + } + + String toJson() => jsonEncode({ + 'SEARCH_BY_PHONE': searchByPhone, + 'INCOMING_CALL': incomingCall, + 'DOUBLE_TAP_REACTION_DISABLED': doubleTapReactionDisabled, + 'SAFE_MODE_NO_PIN': safeModeNoPin, + 'DOUBLE_TAP_REACTION_VALUE': doubleTapReactionValue, + 'FAMILY_PROTECTION': familyProtection, + 'PUSH_DETAILS': pushDetails, + 'HIDDEN': hidden, + 'CHATS_INVITE': chatsInvite, + 'PUSH_NEW_CONTACTS': pushNewContacts, + 'UNSAFE_FILES': unsafeFiles, + 'INACTIVE_TTL': inactiveTtl, + 'SHOW_READ_MARK': showReadMark, + 'ALT_KEYBOARD': altKeyboard, + 'CONTENT_LEVEL_ACCESS': contentLevelAccess, + 'STICKERS_SUGGEST': stickersSuggest, + 'SAFE_MODE': safeMode, + 'AUDIO_TRANSCRIPTION_ENABLED': audioTranscriptionEnabled, + 'hash': hash, + }); + + factory PrivacyConfig.fromJson(String json) { + try { + final map = jsonDecode(json) as Map; + return PrivacyConfig.fromMap(map); + } catch (_) { + return PrivacyConfig.empty(); + } + } + + static PrivacyConfig empty() { + return const PrivacyConfig( + searchByPhone: 'ALL', + incomingCall: 'CONTACTS', + doubleTapReactionDisabled: false, + safeModeNoPin: false, + familyProtection: 'OFF', + pushDetails: false, + hidden: true, + chatsInvite: 'CONTACTS', + pushNewContacts: false, + unsafeFiles: true, + inactiveTtl: '6M', + showReadMark: true, + altKeyboard: false, + contentLevelAccess: false, + stickersSuggest: 'ON', + safeMode: false, + audioTranscriptionEnabled: true, + hash: '', + ); + } +} + +class BlockedContact { + final int id; + final String? firstName; + final String? lastName; + final String? baseUrl; + final int? photoId; + final String status; + final int registrationTime; + final int updateTime; + + const BlockedContact({ + required this.id, + this.firstName, + this.lastName, + this.baseUrl, + this.photoId, + required this.status, + required this.registrationTime, + required this.updateTime, + }); + + factory BlockedContact.fromMap(Map map) { + String? firstName; + String? lastName; + final names = map['names'] as List?; + if (names != null && names.isNotEmpty) { + for (final n in names) { + if (n is Map) { + firstName = n['firstName'] as String?; + lastName = n['lastName'] as String?; + if (n['type'] == 'ONEME') break; + } + } + } + + return BlockedContact( + id: map['id'] as int? ?? 0, + firstName: firstName, + lastName: lastName, + baseUrl: map['baseUrl'] as String?, + photoId: map['photoId'] as int?, + status: map['status']?.toString() ?? 'BLOCKED', + registrationTime: map['registrationTime'] as int? ?? 0, + updateTime: map['updateTime'] as int? ?? 0, + ); + } +} + +class TwoFactorDetails { + final bool enabled; + final String? email; + final String? hint; + + const TwoFactorDetails({required this.enabled, this.email, this.hint}); +} + enum AuthRequestType { startAuth('START_AUTH'), resend('RESEND'), @@ -182,6 +361,303 @@ class AccountModule { Stream get loginStatusStream => _loginStatusController.stream; + Future getPrivacyConfig() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + final saved = await AppDatabase.getPrivacyConfig(accountId); + if (saved != null) { + return PrivacyConfig.fromJson(saved); + } + } + return PrivacyConfig.empty(); + } + + Future> getBlockedContacts() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.contactList, { + 'status': 'BLOCKED', + 'count': 100, + 'from': 0, + }); + _checkPacketError(packet, 'getBlockedContacts'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'getBlockedContacts: неожиданный тип payload: ${data.runtimeType}', + ); + } + final contacts = data['contacts'] as List?; + if (contacts == null) return []; + return contacts + .whereType() + .map((c) => BlockedContact.fromMap(c.cast())) + .toList(); + } + + Future updatePrivacyConfig( + Map settings, + ) async { + _ensureOnline(); + final payload = { + 'settings': {'user': settings}, + }; + final packet = await _api.sendRequest(Opcode.config, payload); + _checkPacketError(packet, 'updatePrivacyConfig'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'updatePrivacyConfig: неожиданный тип payload: ${data.runtimeType}', + ); + } + final user = data['user']; + if (user is! Map) { + throw Exception('updatePrivacyConfig: отсутствует user в payload'); + } + final config = PrivacyConfig.fromMap(user.cast()); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.savePrivacyConfig(accountId, config.toJson()); + } + return config; + } + + // 2FA Creation (when not set) + Future create2faTrack() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authCreateTrack, {'type': 0}); + _checkPacketError(packet, 'create2faTrack'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'create2faTrack: неожиданный тип payload: ${data.runtimeType}', + ); + } + final trackId = data['trackId'] as String?; + if (trackId == null) { + throw Exception('create2faTrack: отсутствует trackId'); + } + return trackId; + } + + Future set2faPassword(String trackId, String password) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authValidatePassword, { + 'trackId': trackId, + 'password': password, + }); + _checkPacketError(packet, 'set2faPassword'); + if (packet.payload != null && packet.payload is! Map) { + throw Exception('set2faPassword: неожиданный ответ'); + } + } + + Future set2faHint(String trackId, String hint) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authValidateHint, { + 'trackId': trackId, + 'hint': hint, + }); + _checkPacketError(packet, 'set2faHint'); + if (packet.payload != null && packet.payload is! Map) { + throw Exception('set2faHint: неожиданный ответ'); + } + } + + Future verify2faEmail(String trackId, String email) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authVerifyEmail, { + 'trackId': trackId, + 'email': email, + }); + _checkPacketError(packet, 'verify2faEmail'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'verify2faEmail: неожиданный тип payload: ${data.runtimeType}', + ); + } + final blockingDuration = data['blockingDuration'] as int? ?? 60; + return blockingDuration; + } + + Future verify2faCode(String trackId, String code) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authCheckEmail, { + 'trackId': trackId, + 'verifyCode': code, + }); + _checkPacketError(packet, 'verify2faCode'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'verify2faCode: неожиданный тип payload: ${data.runtimeType}', + ); + } + final email = data['email'] as String? ?? ''; + return email; + } + + Future confirm2fa({ + required String trackId, + required String password, + String? hint, + }) async { + _ensureOnline(); + final payload = { + 'expectedCapabilities': [0, 3, 4], + 'trackId': trackId, + 'password': password, + }; + if (hint != null) payload['hint'] = hint; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'confirm2fa'); + return _processProfileUpdate(packet); + } + + // 2FA Management (when already set) + Future enter2faPanel() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authCreateTrack, {'type': 0}); + _checkPacketError(packet, 'enter2faPanel'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'enter2faPanel: неожиданный тип payload: ${data.runtimeType}', + ); + } + final trackId = data['trackId'] as String?; + if (trackId == null) { + throw Exception('enter2faPanel: отсутствует trackId'); + } + return trackId; + } + + Future get2faDetails(String trackId) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.auth2faDetails, { + 'trackId': trackId, + }); + _checkPacketError(packet, 'get2faDetails'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'get2faDetails: неожиданный тип payload: ${data.runtimeType}', + ); + } + final password = data['password'] as Map?; + return TwoFactorDetails( + enabled: password?['enabled'] ?? false, + email: password?['email'] as String?, + hint: password?['hint'] as String?, + ); + } + + Future check2faPassword(String trackId, String password) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, { + 'trackId': trackId, + 'password': password, + }); + _checkPacketError(packet, 'check2faPassword'); + final data = packet.payload; + if (data is Map && data['error'] != null) { + throw Exception('Неверный пароль'); + } + } + + Future update2faPassword({ + required String trackId, + required String newPassword, + String? hint, + }) async { + _ensureOnline(); + final validatePacket = await _api.sendRequest(Opcode.authValidatePassword, { + 'trackId': trackId, + 'password': newPassword, + }); + _checkPacketError(validatePacket, 'update2faPassword: validate'); + if (validatePacket.payload != null && validatePacket.payload is! Map) { + throw Exception('update2faPassword: неожиданный ответ при валидации'); + } + + if (hint != null) { + final hintPacket = await _api.sendRequest(Opcode.authValidateHint, { + 'trackId': trackId, + 'hint': hint, + }); + _checkPacketError(hintPacket, 'update2faPassword: hint'); + } + + final payload = { + 'expectedCapabilities': [1, 3], + 'trackId': trackId, + 'password': newPassword, + }; + if (hint != null) payload['hint'] = hint; + + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'update2faPassword'); + return _processProfileUpdate(packet); + } + + Future update2faEmail({ + required String trackId, + required String email, + required String code, + }) async { + _ensureOnline(); + final verifyPacket = await _api.sendRequest(Opcode.authVerifyEmail, { + 'trackId': trackId, + 'email': email, + }); + _checkPacketError(verifyPacket, 'update2faEmail: verify'); + + final codePacket = await _api.sendRequest(Opcode.authCheckEmail, { + 'trackId': trackId, + 'verifyCode': code, + }); + _checkPacketError(codePacket, 'update2faEmail: code'); + + final payload = { + 'expectedCapabilities': [4], + 'trackId': trackId, + }; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'update2faEmail'); + return _processProfileUpdate(packet); + } + + Future remove2fa(String trackId) async { + _ensureOnline(); + final payload = { + 'expectedCapabilities': [5], + 'trackId': trackId, + 'remove2fa': true, + }; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'remove2fa'); + return _processProfileUpdate(packet); + } + + Future _processProfileUpdate(Packet packet) async { + _api.registerPushHandler(Opcode.notifProfile, (p) {}); + await for (final push in _api.pushStream.where( + (p) => p.opcode == Opcode.notifProfile, + )) { + final payload = push.payload; + if (payload is Map) { + final profile = payload['profile']; + if (profile is Map) { + final contact = profile['contact']; + if (contact is Map) { + return ProfileData.fromServerMap(contact.cast()); + } + } + } + } + throw Exception('Не удалось получить обновлённый профиль'); + } + Future requestCode( String phone, { String language = 'ru', diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index ccd6191..f9bb88e 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -14,6 +14,7 @@ class ProfileData { final String country; final int accountStatus; final int updateTime; + final List? profileOptions; ProfileData({ required this.id, @@ -26,6 +27,7 @@ class ProfileData { required this.country, required this.accountStatus, required this.updateTime, + this.profileOptions, }); factory ProfileData.fromServerMap(Map contact) { @@ -44,6 +46,12 @@ class ProfileData { lastName = name['lastName'] as String?; } + final profileOptionsRaw = contact['profileOptions']; + List? profileOptions; + if (profileOptionsRaw is List) { + profileOptions = profileOptionsRaw.map((e) => e as int).toList(); + } + return ProfileData( id: contact['id'] as int, firstName: firstName, @@ -55,21 +63,31 @@ class ProfileData { country: (contact['country'] as String?) ?? '', accountStatus: (contact['accountStatus'] as int?) ?? 0, updateTime: (contact['updateTime'] as int?) ?? 0, + profileOptions: profileOptions, ); } factory ProfileData.fromDbRow(Map row) { + final profileOptionsStr = row['profile_options'] as String?; + List? profileOptions; + if (profileOptionsStr != null && profileOptionsStr.isNotEmpty) { + profileOptions = profileOptionsStr + .split(',') + .map((e) => int.parse(e.trim())) + .toList(); + } return ProfileData( id: row['id'] as int, - firstName: row['first_name'] as String, + firstName: (row['first_name'] as String?) ?? '', lastName: row['last_name'] as String?, - phone: row['phone'] as int, + phone: (row['phone'] as int?) ?? 0, photoId: row['photo_id'] as int?, baseUrl: row['base_url'] as String?, baseRawUrl: row['base_raw_url'] as String?, - country: row['country'] as String, - accountStatus: row['account_status'] as int, - updateTime: row['update_time'] as int, + country: (row['country'] as String?) ?? '', + accountStatus: (row['account_status'] as int?) ?? 0, + updateTime: (row['update_time'] as int?) ?? 0, + profileOptions: profileOptions, ); } @@ -84,6 +102,7 @@ class ProfileData { 'country': country, 'account_status': accountStatus, 'update_time': updateTime, + 'profile_options': profileOptions?.join(','), }; } @@ -119,7 +138,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 6, + version: 7, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -143,6 +162,11 @@ class AppDatabase { if (oldVersion < 6) { await db.execute(_messagesSchema); } + if (oldVersion < 7) { + await db.execute( + 'ALTER TABLE profile ADD COLUMN profile_options TEXT', + ); + } }, ); } @@ -160,7 +184,8 @@ class AppDatabase { country TEXT NOT NULL DEFAULT '', account_status INTEGER NOT NULL DEFAULT 0, update_time INTEGER NOT NULL DEFAULT 0, - is_active INTEGER NOT NULL DEFAULT 0 + is_active INTEGER NOT NULL DEFAULT 0, + profile_options TEXT ) '''); await db.execute(_syncStateSchema); @@ -318,6 +343,30 @@ class AppDatabase { }; } + static Future savePrivacyConfig( + int accountId, + String jsonConfig, + ) async { + final db = await _instance; + await db.insert('sync_state', { + 'account_id': accountId, + 'key': 'privacy_config', + 'value': jsonConfig, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getPrivacyConfig(int accountId) async { + final db = await _instance; + final rows = await db.query( + 'sync_state', + where: 'account_id = ? AND key = ?', + whereArgs: [accountId, 'privacy_config'], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String; + } + static Future close() async { await _db?.close(); _db = null; diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index 8ef642f..69e8c5d 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -2,7 +2,6 @@ import '../protocol/packet.dart'; import '../utils/logger.dart'; import 'connection.dart'; -/// Упаковывает и отправляет пакеты, ведёт счётчик seq. class PacketSender { int _seq = 0; @@ -13,7 +12,6 @@ class PacketSender { return _seq; } - /// Отправляет пакет, возвращает присвоенный seq. int send(Connection connection, int opcode, Map payload) { final seq = _nextSeq(); final data = packPacket(opcode, payload, seq: seq); diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart new file mode 100644 index 0000000..dca05e4 --- /dev/null +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -0,0 +1,1488 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show accountModule; +import '../../../backend/modules/account.dart' show TwoFactorDetails; +import '../../../core/storage/app_database.dart'; +import '../../widgets/custom_notification.dart'; + +class PasswordEntryScreen extends StatefulWidget { + const PasswordEntryScreen({super.key}); + + @override + State createState() => _PasswordEntryScreenState(); +} + +class _PasswordEntryScreenState extends State { + bool _isLoading = true; + bool _is2faEnabled = false; + String? _email; + String? _hint; + + @override + void initState() { + super.initState(); + _check2faStatus(); + } + + Future _check2faStatus() async { + try { + final profile = await AppDatabase.loadActiveProfile(); + if (mounted) { + setState(() { + _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_isLoading) { + return Scaffold( + backgroundColor: cs.surface, + body: Center(child: CircularProgressIndicator(color: cs.primary)), + ); + } + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildMainSection(cs), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Text( + 'Пароль для входа', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ], + ), + ); + } + + Widget _buildMainSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _is2faEnabled ? Symbols.lock : Symbols.lock_open, + color: cs.primary, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _is2faEnabled + ? 'Пароль установлен' + : 'Пароль не установлен', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _is2faEnabled + ? 'Используется для дополнительной защиты' + : 'Двухфакторная аутентификация', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Symbols.settings, + label: _is2faEnabled ? 'Изменить пароль' : 'Установить пароль', + isLast: _is2faEnabled, + onTap: () => _navigateToPasswordSetup(context, cs), + ), + if (_is2faEnabled) ...[ + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Icons.email_outlined, + label: 'Изменить почту', + isLast: false, + onTap: () => _navigateToEmailChange(context, cs), + ), + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Icons.delete_outline, + label: 'Удалить пароль', + isLast: true, + textColor: cs.error, + onTap: () => _showRemoveConfirmation(context, cs), + ), + ], + ], + ), + ); + } + + Widget _buildActionRow( + ColorScheme cs, { + required IconData icon, + required String label, + required bool isLast, + required VoidCallback onTap, + Color? textColor, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + icon, + color: textColor ?? cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: textColor ?? cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + ], + ); + } + + void _navigateToPasswordSetup(BuildContext context, ColorScheme cs) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const TwoFactorSetupScreen()), + ); + } + + void _navigateToEmailChange(BuildContext context, ColorScheme cs) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorEmailChangeScreen(), + ), + ); + } + + void _showRemoveConfirmation(BuildContext context, ColorScheme cs) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text('Удалить пароль?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы уверены, что хотите удалить пароль для входа? Это ослабит защиту вашего аккаунта.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorRemoveScreen(), + ), + ); + }, + child: Text('Удалить', style: TextStyle(color: cs.error)), + ), + ], + ), + ); + } +} + +class TwoFactorSetupScreen extends StatefulWidget { + const TwoFactorSetupScreen({super.key}); + + @override + State createState() => _TwoFactorSetupScreenState(); +} + +class _TwoFactorSetupScreenState extends State { + final _passwordController = TextEditingController(); + final _hintController = TextEditingController(); + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + + int _step = 0; + bool _isLoading = false; + String? _trackId; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _hintController.dispose(); + _emailController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _nextStep() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + switch (_step) { + case 0: + final trackId = await accountModule.create2faTrack(); + setState(() { + _trackId = trackId; + _step = 1; + }); + break; + case 1: + if (_passwordController.text.length < 6) { + setState( + () => _errorMessage = 'Пароль должен быть минимум 6 символов', + ); + break; + } + await accountModule.set2faPassword( + _trackId!, + _passwordController.text, + ); + setState(() => _step = 2); + break; + case 2: + if (_hintController.text.isNotEmpty) { + await accountModule.set2faHint(_trackId!, _hintController.text); + } + setState(() => _step = 3); + break; + case 3: + if (!_emailController.text.contains('@')) { + setState(() => _errorMessage = 'Введите корректный email'); + break; + } + await accountModule.verify2faEmail(_trackId!, _emailController.text); + setState(() => _step = 4); + break; + case 4: + if (_codeController.text.length != 6) { + setState(() => _errorMessage = 'Введите 6-значный код'); + break; + } + await accountModule.verify2faCode(_trackId!, _codeController.text); + await accountModule.confirm2fa( + trackId: _trackId!, + password: _passwordController.text, + hint: _hintController.text.isEmpty ? null : _hintController.text, + ); + if (mounted) { + showCustomNotification(context, 'Пароль установлен'); + Navigator.popUntil( + context, + (route) => + route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + break; + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Установка пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: _buildStepContent(cs), + ); + } + + Widget _buildStepContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStepIndicator(cs), + const SizedBox(height: 24), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Symbols.error, color: cs.error, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: cs.onErrorContainer, + fontSize: 14, + ), + ), + ), + ], + ), + ), + _buildCurrentStep(cs), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), + ), + ), + ], + ), + ); + } + + Widget _buildStepIndicator(ColorScheme cs) { + final steps = ['Пароль', 'Подсказка', 'Почта', 'Код', 'Готово']; + return Row( + children: List.generate(steps.length, (index) { + final isActive = index <= _step; + final isCurrent = index == _step; + return Expanded( + child: Column( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive ? cs.primary : cs.surfaceContainerHighest, + ), + child: Center( + child: isActive + ? Icon(Symbols.check, color: cs.onPrimary, size: 16) + : Text( + '${index + 1}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + steps[index], + style: TextStyle( + color: isCurrent ? cs.primary : cs.onSurfaceVariant, + fontSize: 11, + fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ); + }), + ); + } + + Widget _buildCurrentStep(ColorScheme cs) { + switch (_step) { + case 0: + return _buildPasswordField(cs); + case 1: + return _buildPasswordConfirmField(cs); + case 2: + return _buildHintField(cs); + case 3: + return _buildEmailField(cs); + case 4: + return _buildCodeField(cs); + default: + return const SizedBox(); + } + } + + Widget _buildPasswordField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Придумайте пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Минимум 6 символов', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Введите пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildPasswordConfirmField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подтвердите пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Введите пароль ещё раз', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Повторите пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildHintField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подсказка для пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Необязательно', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _hintController, + decoration: InputDecoration( + hintText: 'Введите подсказку (необязательно)', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildEmailField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Привяжите email', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Для восстановления пароля', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'example@mail.ru', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildCodeField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Введите код', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Код отправлен на ${_emailController.text}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: '000000', + counterText: '', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } +} + +class TwoFactorManageScreen extends StatefulWidget { + const TwoFactorManageScreen({super.key}); + + @override + State createState() => _TwoFactorManageScreenState(); +} + +class _TwoFactorManageScreenState extends State { + final _passwordController = TextEditingController(); + bool _isLoading = false; + bool _isAuthenticated = false; + String? _trackId; + TwoFactorDetails? _details; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _authenticate() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + _trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(_trackId!, _passwordController.text); + final details = await accountModule.get2faDetails(_trackId!); + setState(() { + _isAuthenticated = true; + _details = details; + }); + } catch (e) { + setState(() => _errorMessage = 'Неверный пароль'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Управление паролем', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: _isAuthenticated ? _buildManageContent(cs) : _buildAuthContent(cs), + ); + } + + Widget _buildAuthContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Введите текущий пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _authenticate, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Продолжить'), + ), + ), + ], + ), + ); + } + + Widget _buildManageContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Icon(Symbols.lock, color: cs.primary, size: 48), + const SizedBox(height: 12), + Text( + 'Пароль установлен', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + if (_details?.email != null) ...[ + const SizedBox(height: 4), + Text( + _details!.email!, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ], + if (_details?.hint != null && _details!.hint!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Подсказка: ${_details!.hint}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorPasswordChangeScreen(), + ), + ); + }, + icon: const Icon(Symbols.edit), + label: const Text('Изменить пароль'), + style: OutlinedButton.styleFrom( + foregroundColor: cs.primary, + side: BorderSide(color: cs.outline), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ); + } +} + +class TwoFactorPasswordChangeScreen extends StatefulWidget { + const TwoFactorPasswordChangeScreen({super.key}); + + @override + State createState() => + _TwoFactorPasswordChangeScreenState(); +} + +class _TwoFactorPasswordChangeScreenState + extends State { + final _passwordController = TextEditingController(); + final _hintController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _hintController.dispose(); + super.dispose(); + } + + Future _changePassword() async { + if (_passwordController.text.length < 6) { + setState(() => _errorMessage = 'Пароль должен быть минимум 6 символов'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(trackId, _passwordController.text); + await accountModule.update2faPassword( + trackId: trackId, + newPassword: _passwordController.text, + hint: _hintController.text.isEmpty ? null : _hintController.text, + ); + if (mounted) { + showCustomNotification(context, 'Пароль изменён'); + Navigator.popUntil( + context, + (route) => route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Изменить пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Новый пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Введите новый пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Подсказка', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _hintController, + decoration: InputDecoration( + hintText: 'Введите подсказку (необязательно)', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _changePassword, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Сохранить'), + ), + ), + ], + ), + ), + ); + } +} + +class TwoFactorEmailChangeScreen extends StatefulWidget { + const TwoFactorEmailChangeScreen({super.key}); + + @override + State createState() => + _TwoFactorEmailChangeScreenState(); +} + +class _TwoFactorEmailChangeScreenState + extends State { + final _passwordController = TextEditingController(); + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + int _step = 0; + bool _isLoading = false; + String? _trackId; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _emailController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _nextStep() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + switch (_step) { + case 0: + _trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword( + _trackId!, + _passwordController.text, + ); + setState(() => _step = 1); + break; + case 1: + if (!_emailController.text.contains('@')) { + setState(() => _errorMessage = 'Введите корректный email'); + break; + } + await accountModule.verify2faEmail(_trackId!, _emailController.text); + setState(() => _step = 2); + break; + case 2: + if (_codeController.text.length != 6) { + setState(() => _errorMessage = 'Введите 6-значный код'); + break; + } + await accountModule.verify2faCode(_trackId!, _codeController.text); + await accountModule.update2faEmail( + trackId: _trackId!, + email: _emailController.text, + code: _codeController.text, + ); + if (mounted) { + showCustomNotification(context, 'Почта изменена'); + Navigator.popUntil( + context, + (route) => + route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + break; + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Изменить почту', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_step == 0) ...[ + Text( + 'Введите текущий пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ] else ...[ + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + if (_step == 1) ...[ + Text( + 'Новая почта', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'example@mail.ru', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ] else ...[ + Text( + 'Введите код', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Код отправлен на ${_emailController.text}', + style: TextStyle(color: cs.onSurfaceVariant), + ), + const SizedBox(height: 16), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: '000000', + counterText: '', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ], + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), + ), + ), + ], + ), + ), + ); + } +} + +class TwoFactorRemoveScreen extends StatefulWidget { + const TwoFactorRemoveScreen({super.key}); + + @override + State createState() => _TwoFactorRemoveScreenState(); +} + +class _TwoFactorRemoveScreenState extends State { + final _passwordController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _remove2fa() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(trackId, _passwordController.text); + await accountModule.remove2fa(trackId); + if (mounted) { + showCustomNotification(context, 'Пароль удалён'); + Navigator.popUntil( + context, + (route) => route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Удаление пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Symbols.warning, color: cs.error), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Внимание! После удаления пароля защита вашего аккаунта ослабнет.', + style: TextStyle(color: cs.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + 'Введите пароль для подтверждения', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _remove2fa, + style: FilledButton.styleFrom( + backgroundColor: cs.error, + foregroundColor: cs.onError, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onError, + ), + ) + : const Text('Удалить пароль'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index a2c84d0..676396e 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -1,6 +1,12 @@ +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show accountModule; +import '../../../backend/modules/account.dart' + show PrivacyConfig, BlockedContact; +import '../../../core/storage/app_database.dart'; import '../../widgets/custom_notification.dart'; +import 'password_entry_screen.dart'; class SecurityScreen extends StatefulWidget { const SecurityScreen({super.key}); @@ -9,8 +15,73 @@ class SecurityScreen extends StatefulWidget { State createState() => _SecurityScreenState(); } -class _SecurityScreenState extends State { - bool _safeMode = false; +class _SecurityScreenState extends State + with SingleTickerProviderStateMixin { + bool _isLoading = true; + bool _isSaving = false; + bool _is2faEnabled = false; + PrivacyConfig? _privacyConfig; + List _blockedContacts = []; + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadData(); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + Future _loadData() async { + try { + final results = await Future.wait([ + accountModule.getPrivacyConfig(), + accountModule.getBlockedContacts(), + AppDatabase.loadActiveProfile(), + ]); + if (mounted) { + setState(() { + _privacyConfig = results[0] as PrivacyConfig; + _blockedContacts = results[1] as List; + final profile = results[2] as ProfileData?; + _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка загрузки: $e'); + setState(() => _isLoading = false); + } + } + } + + Future _updateSetting(String key, dynamic value) async { + if (_isSaving) return; + setState(() => _isSaving = true); + try { + final newConfig = await accountModule.updatePrivacyConfig({key: value}); + if (mounted) { + setState(() => _privacyConfig = newConfig); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка сохранения: $e'); + } + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } @override Widget build(BuildContext context) { @@ -20,53 +91,100 @@ class _SecurityScreenState extends State { backgroundColor: cs.surface, body: SafeArea( bottom: false, - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter(child: _buildAppBar(context, cs)), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildTopSection(cs), + child: _isLoading + ? _buildShimmer(cs) + : CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildTopSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildPrivacySettings(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + child: _buildInfoLabel(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildConfidentialSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + child: _buildBlacklistSection(cs), + ), + ), + ], ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _buildSafeModeSection(cs), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), - child: _buildInfoLabel(cs), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildOnlineSection(cs), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), - child: _buildBlacklistSection(cs), - ), - ), - ], - ), ), ); } + Widget _buildShimmer(ColorScheme cs) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + children: [ + _buildAppBar(context, cs), + _buildShimmerSection(cs, height: 104), + const SizedBox(height: 12), + _buildShimmerSection(cs, height: 280), + const SizedBox(height: 20), + _buildShimmerSection(cs, height: 220), + const SizedBox(height: 12), + _buildShimmerSection(cs, height: 120), + ], + ), + ); + } + + Widget _buildShimmerSection(ColorScheme cs, {required double height}) { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.2 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + height: height, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ); + }, + ); + } + Widget _buildAppBar(BuildContext context, ColorScheme cs) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Row( children: [ IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface, size: 24, weight: 400), + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), onPressed: () => Navigator.pop(context), ), const SizedBox(width: 4), @@ -79,11 +197,37 @@ class _SecurityScreenState extends State { fontFamily: 'Outfit', ), ), + const Spacer(), + if (_isSaving) + Padding( + padding: const EdgeInsets.only(right: 16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + ), ], ), ); } + String _getPrivacyLabel(String value) { + switch (value) { + case 'ALL': + return 'Все'; + case 'CONTACTS': + return 'Мои контакты'; + case 'NONE': + return 'Никто'; + default: + return value; + } + } + Widget _buildTopSection(ColorScheme cs) { return Container( decoration: BoxDecoration( @@ -92,19 +236,14 @@ class _SecurityScreenState extends State { ), child: Column( children: [ - _buildNavRow( - cs, - icon: Symbols.key, - label: 'Пароль для входа', - subtitle: 'Отключён', - trailing: _buildWarningBadge(cs), - isLast: false, - ), + _buildPasswordRow(cs), _buildNavRow( cs, icon: Symbols.shield, label: 'Семейная защита', - subtitle: 'Отключена', + subtitle: _privacyConfig?.familyProtection == 'ON' + ? 'Включена' + : 'Отключена', isLast: true, ), ], @@ -112,7 +251,82 @@ class _SecurityScreenState extends State { ); } - Widget _buildSafeModeSection(ColorScheme cs) { + Widget _buildPasswordRow(ColorScheme cs) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PasswordEntryScreen(), + ), + ); + }, + borderRadius: BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.key, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Пароль для входа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _is2faEnabled ? 'Включён' : 'Отключён', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + _buildWarningBadge(cs), + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildPrivacySettings(ColorScheme cs) { + final isSafeMode = _privacyConfig?.safeMode ?? false; return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -124,7 +338,12 @@ class _SecurityScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( children: [ - Icon(Symbols.lock, color: cs.onSurfaceVariant, size: 22, weight: 400), + Icon( + Symbols.lock, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), const SizedBox(width: 16), Expanded( child: Column( @@ -140,7 +359,7 @@ class _SecurityScreenState extends State { ), const SizedBox(height: 2), Text( - 'Доступно только в мобильном приложении', + 'Скрывает личную информацию', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -150,32 +369,366 @@ class _SecurityScreenState extends State { ), ), Switch( - value: _safeMode, - onChanged: (v) => setState(() => _safeMode = v), + value: isSafeMode, + onChanged: (v) => showCustomNotification( + context, + 'Изменение настроек пока недоступно', + ), ), ], ), ), - if (_safeMode) ...[ + if (isSafeMode) ...[ Padding( padding: const EdgeInsets.only(left: 58), - child: Divider(height: 1, thickness: 1, color: cs.outlineVariant.withValues(alpha: 0.35)), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + _buildSubRow( + cs, + label: 'Найти меня по номеру', + value: _getPrivacyLabel(_privacyConfig?.searchByPhone ?? 'ALL'), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Кто может мне звонить', + value: _getPrivacyLabel( + _privacyConfig?.incomingCall ?? 'CONTACTS', + ), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Кто может приглашать в чаты', + value: _getPrivacyLabel( + _privacyConfig?.chatsInvite ?? 'CONTACTS', + ), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Показывать контакт', + value: _privacyConfig?.contentLevelAccess == true + ? 'Безопасный' + : 'Весь', + isLast: true, + ), + ], + if (!isSafeMode) ...[ + Padding( + padding: const EdgeInsets.only(left: 20), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.phone, + label: 'Кто может мне звонить', + value: _getPrivacyLabel( + _privacyConfig?.incomingCall ?? 'CONTACTS', + ), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Кто может мне звонить', + currentValue: _privacyConfig?.incomingCall ?? 'CONTACTS', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('INCOMING_CALL', value), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.group, + label: 'Кто может приглашать в чаты', + value: _getPrivacyLabel( + _privacyConfig?.chatsInvite ?? 'CONTACTS', + ), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Кто может приглашать в чаты', + currentValue: _privacyConfig?.chatsInvite ?? 'CONTACTS', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('CHATS_INVITE', value), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.contact_phone, + label: 'Найти меня по номеру', + value: _getPrivacyLabel(_privacyConfig?.searchByPhone ?? 'ALL'), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Найти меня по номеру', + currentValue: _privacyConfig?.searchByPhone ?? 'ALL', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('SEARCH_BY_PHONE', value), + ), + ), + _buildOptionRow( + cs, + icon: Icons.visibility_off_outlined, + label: 'Видеть статус «в сети»', + value: _privacyConfig?.hidden == true ? 'Никто' : 'Мои контакты', + isLast: true, + onTap: () => _showHiddenStatusSheet(context, cs), ), - _buildSubRow(cs, label: 'Найти меня по номеру', value: 'Могут все', isLast: false), - _buildSubRow(cs, label: 'Позвонить', value: 'Могут все', isLast: false), - _buildSubRow(cs, label: 'Пригласить в чат', value: 'Могут контакты', isLast: false), - _buildSubRow(cs, label: 'Показывать контент', value: 'Весь', isLast: true), ], ], ), ); } + void _showOptionSheet( + BuildContext context, + ColorScheme cs, { + required String title, + required String currentValue, + required List<(String, String)> options, + required void Function(String) onSelect, + }) { + showModalBottomSheet( + context: context, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ...options.map((option) { + final isSelected = option.$1 == currentValue; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.pop(context); + onSelect(option.$1); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + child: Row( + children: [ + Expanded( + child: Text( + option.$2, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + ), + ), + ), + if (isSelected) + Icon(Symbols.check, color: cs.primary, size: 20), + ], + ), + ), + ), + ); + }), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + void _showHiddenStatusSheet(BuildContext context, ColorScheme cs) { + final currentValue = _privacyConfig?.hidden == true ? 'NONE' : 'CONTACTS'; + + if (currentValue == 'NONE') { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы не сможете видеть статусы посещения других пользователей.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _updateSetting('HIDDEN', false); + }, + child: Text('Да', style: TextStyle(color: cs.primary)), + ), + ], + ), + ); + return; + } + + showModalBottomSheet( + context: context, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + 'Видеть статус «в сети»', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _buildOptionSheetItem( + cs, + 'Мои контакты', + currentValue == 'CONTACTS', + () { + Navigator.pop(context); + _updateSetting('HIDDEN', false); + }, + ), + _buildOptionSheetItem(cs, 'Никто', currentValue == 'NONE', () { + Navigator.pop(context); + _showHiddenStatusConfirmDialog(context, cs); + }, isLast: true), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + void _showHiddenStatusConfirmDialog(BuildContext context, ColorScheme cs) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы не сможете видеть статусы посещения других пользователей.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _updateSetting('HIDDEN', true); + }, + child: Text('Да', style: TextStyle(color: cs.primary)), + ), + ], + ), + ); + } + + Widget _buildOptionSheetItem( + ColorScheme cs, + String label, + bool isSelected, + VoidCallback onTap, { + bool isLast = false, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + ), + if (isSelected) + Icon(Symbols.check, color: cs.primary, size: 20), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 20), + child: Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + ], + ); + } + Widget _buildInfoLabel(ColorScheme cs) { return Padding( padding: const EdgeInsets.only(left: 4, bottom: 0), child: Text( - 'ИНФОРМАЦИЯ', + 'КОНФИДЕНЦИАЛЬНОСТЬ', style: TextStyle( color: cs.onSurfaceVariant.withValues(alpha: 0.6), fontSize: 12, @@ -186,24 +739,53 @@ class _SecurityScreenState extends State { ); } - Widget _buildOnlineSection(ColorScheme cs) { + Widget _buildConfidentialSection(ColorScheme cs) { return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), ), - child: _buildNavRow( - cs, - icon: null, - label: 'Видеть статус «в сети»', - value: 'Никто', - isLast: true, - noIcon: true, + child: Column( + children: [ + _buildSwitchRow( + cs, + icon: Symbols.description, + label: 'Галочки «Прочитано»', + value: _privacyConfig?.showReadMark ?? true, + isLast: false, + onChanged: (v) => _updateSetting('SHOW_READ_MARK', v), + ), + _buildSwitchRow( + cs, + icon: Symbols.keyboard_alt, + label: 'Альтернативная клавиатура', + value: _privacyConfig?.altKeyboard ?? false, + isLast: false, + onChanged: (v) => _updateSetting('ALT_KEYBOARD', v), + ), + _buildSwitchRow( + cs, + icon: Symbols.warning, + label: 'Принимать опасные файлы', + value: _privacyConfig?.unsafeFiles ?? true, + isLast: false, + onChanged: (v) => _updateSetting('UNSAFE_FILES', v), + ), + _buildSwitchRow( + cs, + icon: Icons.mic_none_outlined, + label: 'Транскрибация аудио', + value: _privacyConfig?.audioTranscriptionEnabled ?? true, + isLast: true, + onChanged: (v) => _updateSetting('AUDIO_TRANSCRIPTION_ENABLED', v), + ), + ], ), ); } Widget _buildBlacklistSection(ColorScheme cs) { + final count = _blockedContacts.length; return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -212,12 +794,22 @@ class _SecurityScreenState extends State { child: Material( color: Colors.transparent, child: InkWell( - onTap: () => showCustomNotification(context, 'Чёрный список'), + onTap: () => showCustomNotification( + context, + 'Чёрный список: $count контактов', + ), borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( children: [ + Icon( + Symbols.block, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -232,7 +824,7 @@ class _SecurityScreenState extends State { ), const SizedBox(height: 2), Text( - 'Список тех, кто не может вам писать, звонить и добавлять в чаты', + '$count ${_getBlockedCountText(count)}', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -242,7 +834,12 @@ class _SecurityScreenState extends State { ), ), const SizedBox(width: 8), - Icon(Symbols.chevron_right, color: cs.outline, size: 20, weight: 400), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), ], ), ), @@ -251,15 +848,22 @@ class _SecurityScreenState extends State { ); } + String _getBlockedCountText(int count) { + if (count == 0) return 'контактов'; + final mod = count % 10; + if (mod == 1 && count != 11) return 'контакт'; + if (mod >= 2 && mod <= 4 && (count < 10 || count > 20)) return 'контакта'; + return 'контактов'; + } + Widget _buildNavRow( ColorScheme cs, { - required IconData? icon, + required IconData icon, required String label, String? subtitle, String? value, Widget? trailing, required bool isLast, - bool noIcon = false, }) { return Column( children: [ @@ -274,10 +878,8 @@ class _SecurityScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( children: [ - if (!noIcon) ...[ - Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), - const SizedBox(width: 16), - ], + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), Expanded( child: subtitle != null ? Column( @@ -320,7 +922,12 @@ class _SecurityScreenState extends State { ), if (trailing != null) trailing, const SizedBox(width: 4), - Icon(Symbols.chevron_right, color: cs.outline, size: 20, weight: 400), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), ], ), ), @@ -329,7 +936,73 @@ class _SecurityScreenState extends State { if (!isLast) Padding( padding: const EdgeInsets.only(left: 58), - child: Divider(height: 1, thickness: 1, color: cs.outlineVariant.withValues(alpha: 0.35)), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildOptionRow( + ColorScheme cs, { + required IconData icon, + required String label, + required String value, + required bool isLast, + required VoidCallback onTap, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + value, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), ), ], ); @@ -365,7 +1038,12 @@ class _SecurityScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(width: 4), - Icon(Symbols.chevron_right, color: cs.outline, size: 18, weight: 400), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 18, + weight: 400, + ), ], ), ), @@ -383,15 +1061,69 @@ class _SecurityScreenState extends State { ); } + Widget _buildSwitchRow( + ColorScheme cs, { + required IconData icon, + required String label, + required bool value, + required bool isLast, + required void Function(bool) onChanged, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(!value), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + Widget _buildWarningBadge(ColorScheme cs) { return Container( width: 22, height: 22, - decoration: BoxDecoration( - color: cs.error, - shape: BoxShape.circle, + decoration: BoxDecoration(color: cs.error, shape: BoxShape.circle), + child: Icon( + Symbols.priority_high, + color: cs.onError, + size: 14, + weight: 700, ), - child: Icon(Symbols.priority_high, color: cs.onError, size: 14, weight: 700), ); } } diff --git a/pubspec.lock b/pubspec.lock index 66aac21..62a257f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: From 1a130b80ffffcdd2aed1710ccb0ff75b8b379644 Mon Sep 17 00:00:00 2001 From: sergejwinston Date: Mon, 6 Apr 2026 23:11:26 +0700 Subject: [PATCH 057/323] Add AGENTS.md to .gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 14601b5..0d6d7ea 100644 --- a/.gitignore +++ b/.gitignore @@ -125,4 +125,7 @@ app.*.symbols !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -!/dev/ci/**/Gemfile.lock \ No newline at end of file +!/dev/ci/**/Gemfile.lock + + +AGENTS.md \ No newline at end of file From 37e320a52b0476946df15d2ebc4659d714c90d8c Mon Sep 17 00:00:00 2001 From: sergejwinston Date: Tue, 7 Apr 2026 02:51:34 +0700 Subject: [PATCH 058/323] feat(auth): integrate SmartAuth for SMS code auto-fill and update localization strings --- android/gradle.properties | 4 ++ .../auth/code_confirmation_screen.dart | 65 +++++++++++++++++-- lib/frontend/screens/auth/login_screen.dart | 9 --- lib/l10n/app_en.arb | 15 ++--- lib/l10n/app_localizations.dart | 20 ++---- lib/l10n/app_localizations_en.dart | 18 +++-- lib/l10n/app_localizations_ru.dart | 9 +-- lib/l10n/app_ru.arb | 6 +- pubspec.lock | 8 +++ pubspec.yaml | 1 + 10 files changed, 101 insertions(+), 54 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index 492b2f1..398cde8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true kotlin.incremental=false dev.steenbakker.mobile_scanner.useUnbundled=true +systemProp.socksProxyHost=127.0.0.1 +systemProp.socksProxyPort=10808 +systemProp.https.proxyHost=127.0.0.1 +systemProp.https.proxyPort=10808 diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 3840751..21a2194 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:smart_auth/smart_auth.dart'; import '../chats/chat_list_screen.dart'; import 'password_2fa_screen.dart'; import '../../../main.dart'; @@ -26,9 +28,11 @@ class _CodeConfirmationScreenState extends State with TickerProviderStateMixin { final TextEditingController _codeController = TextEditingController(); final FocusNode _focusNode = FocusNode(); + final SmartAuth _smartAuth = SmartAuth.instance; int _timerSeconds = 30; Timer? _timer; Timer? _errorTimer; + bool _isListeningSmsConsent = false; String? _errorMessage; late AnimationController _shakeController; @@ -41,6 +45,7 @@ class _CodeConfirmationScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); + _startSmsCodeListener(); _shakeController = AnimationController( vsync: this, @@ -56,10 +61,63 @@ class _CodeConfirmationScreenState extends State ]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear)); } + Future _startSmsCodeListener() async { + if (defaultTargetPlatform != TargetPlatform.android) return; + if (_isListeningSmsConsent) return; + + _isListeningSmsConsent = true; + final result = await _smartAuth.getSmsWithUserConsentApi(matcher: r'\d{6}'); + _isListeningSmsConsent = false; + + if (!mounted || !result.hasData) return; + + final receivedCode = result.data?.code; + if (receivedCode == null || receivedCode.isEmpty) return; + + _applyAutoFillCode(receivedCode); + } + + Future _restartSmsCodeListener() async { + if (defaultTargetPlatform != TargetPlatform.android) return; + await _smartAuth.removeUserConsentApiListener(); + _isListeningSmsConsent = false; + await _startSmsCodeListener(); + } + + void _handleCodeChanged(String value) { + setState(() { + if (_errorMessage != null) { + _errorMessage = null; + } + }); + if (value.length == 6) { + _verifyCode(); + } + } + + void _applyAutoFillCode(String rawCode) { + final digitsOnly = rawCode.replaceAll(RegExp(r'\D'), ''); + if (digitsOnly.isEmpty) return; + + final nextCode = digitsOnly.length <= 6 + ? digitsOnly + : digitsOnly.substring(0, 6); + + if (_codeController.text == nextCode) return; + + _codeController.value = TextEditingValue( + text: nextCode, + selection: TextSelection.collapsed(offset: nextCode.length), + ); + + _handleCodeChanged(nextCode); + } + @override void dispose() { _timer?.cancel(); _errorTimer?.cancel(); + unawaited(_smartAuth.removeUserConsentApiListener()); _shakeController.dispose(); _codeController.dispose(); _focusNode.dispose(); @@ -92,6 +150,7 @@ class _CodeConfirmationScreenState extends State void _resendCode() { if (_timerSeconds == 0) { _startTimer(); + unawaited(_restartSmsCodeListener()); } } @@ -206,11 +265,7 @@ class _CodeConfirmationScreenState extends State FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6), ], - onChanged: (value) { - if (hasError) setState(() => _errorMessage = null); - setState(() {}); - if (value.length == 6) _verifyCode(); - }, + onChanged: _handleCodeChanged, ), ), ), diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 579f250..08f74e8 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -795,15 +795,6 @@ class _LoginScreenState extends State { height: 1.4, ), children: [ - TextSpan( - text: l10n.loginTermsIntro, - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), TextSpan( text: l10n.loginTermsLink, style: GoogleFonts.inter( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aa424d8..7ec3f63 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,23 +1,22 @@ { "@@locale": "en", - "loginTitle": "Sign in to Komet", + "loginTitle": "Log in to Komet", "loginSubtitle": "Check your country code and enter your\nphone number.", "loginCountry": "Country", "loginPhoneNumber": "Phone number", "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Other sign-in methods", - "loginTermsIntro": "By continuing, you agree to \n", - "loginTermsLink": "the terms of use", - "loginTermsOfUse": "Terms of use", + "loginTermsLink": "Terms of Use «Komet»", + "loginTermsOfUse": "Terms of Use «Komet»", "loginConfirmPhoneTitle": "Is this the correct number?", "loginEdit": "Change", "loginDone": "Done", - "loginReadTermsNotification": "Please read the terms of use first", + "loginReadTermsNotification": "Please read the Terms of Use «Komet»", "loginSpoofRedacted": "Spoof redaction", "loginProxy": "Proxy", - "loginSignInWithQr": "Sign in with QR code", - "loginSignInWithToken": "Sign in with token", - "loginSignInWithSessionFile": "Sign in with session file", + "loginSignInWithQr": "Log in with QR code", + "loginSignInWithToken": "Log in with token", + "loginSignInWithSessionFile": "Log in with session file", "loginLanguage": "Language", "languageNameRu": "Русский", "languageNameEn": "English", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b0614d5..67816f8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -101,7 +101,7 @@ abstract class AppLocalizations { /// No description provided for @loginTitle. /// /// In en, this message translates to: - /// **'Sign in to Komet'** + /// **'Log in to Komet'** String get loginTitle; /// No description provided for @loginSubtitle. @@ -134,22 +134,16 @@ abstract class AppLocalizations { /// **'Other sign-in methods'** String get loginOtherSignInMethods; - /// No description provided for @loginTermsIntro. - /// - /// In en, this message translates to: - /// **'By continuing, you agree to \n'** - String get loginTermsIntro; - /// No description provided for @loginTermsLink. /// /// In en, this message translates to: - /// **'the terms of use'** + /// **'Terms of Use «Komet»'** String get loginTermsLink; /// No description provided for @loginTermsOfUse. /// /// In en, this message translates to: - /// **'Terms of use'** + /// **'Terms of Use «Komet»'** String get loginTermsOfUse; /// No description provided for @loginConfirmPhoneTitle. @@ -173,7 +167,7 @@ abstract class AppLocalizations { /// No description provided for @loginReadTermsNotification. /// /// In en, this message translates to: - /// **'Please read the terms of use first'** + /// **'Please read the Terms of Use «Komet»'** String get loginReadTermsNotification; /// No description provided for @loginSpoofRedacted. @@ -191,19 +185,19 @@ abstract class AppLocalizations { /// No description provided for @loginSignInWithQr. /// /// In en, this message translates to: - /// **'Sign in with QR code'** + /// **'Log in with QR code'** String get loginSignInWithQr; /// No description provided for @loginSignInWithToken. /// /// In en, this message translates to: - /// **'Sign in with token'** + /// **'Log in with token'** String get loginSignInWithToken; /// No description provided for @loginSignInWithSessionFile. /// /// In en, this message translates to: - /// **'Sign in with session file'** + /// **'Log in with session file'** String get loginSignInWithSessionFile; /// No description provided for @loginLanguage. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5ddbe9b..3f6e67e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override - String get loginTitle => 'Sign in to Komet'; + String get loginTitle => 'Log in to Komet'; @override String get loginSubtitle => @@ -28,13 +28,10 @@ class AppLocalizationsEn extends AppLocalizations { String get loginOtherSignInMethods => 'Other sign-in methods'; @override - String get loginTermsIntro => 'By continuing, you agree to \n'; + String get loginTermsLink => 'Terms of Use «Komet»'; @override - String get loginTermsLink => 'the terms of use'; - - @override - String get loginTermsOfUse => 'Terms of use'; + String get loginTermsOfUse => 'Terms of Use «Komet»'; @override String get loginConfirmPhoneTitle => 'Is this the correct number?'; @@ -46,7 +43,8 @@ class AppLocalizationsEn extends AppLocalizations { String get loginDone => 'Done'; @override - String get loginReadTermsNotification => 'Please read the terms of use first'; + String get loginReadTermsNotification => + 'Please read the Terms of Use «Komet»'; @override String get loginSpoofRedacted => 'Spoof redaction'; @@ -55,13 +53,13 @@ class AppLocalizationsEn extends AppLocalizations { String get loginProxy => 'Proxy'; @override - String get loginSignInWithQr => 'Sign in with QR code'; + String get loginSignInWithQr => 'Log in with QR code'; @override - String get loginSignInWithToken => 'Sign in with token'; + String get loginSignInWithToken => 'Log in with token'; @override - String get loginSignInWithSessionFile => 'Sign in with session file'; + String get loginSignInWithSessionFile => 'Log in with session file'; @override String get loginLanguage => 'Language'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e63e96d..63d6d7d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -28,13 +28,10 @@ class AppLocalizationsRu extends AppLocalizations { String get loginOtherSignInMethods => 'Другие способы входа'; @override - String get loginTermsIntro => 'Продолжая, вы соглашаетесь с \n'; + String get loginTermsLink => 'Условия использования «Komet»'; @override - String get loginTermsLink => 'пользовательскими соглашениями'; - - @override - String get loginTermsOfUse => 'Условия использования'; + String get loginTermsOfUse => 'Условия использования «Komet»'; @override String get loginConfirmPhoneTitle => 'Это правильный номер?'; @@ -47,7 +44,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginReadTermsNotification => - 'Сначала прочитайте условия использования'; + 'Сначала прочитайте условия использования «Komet»'; @override String get loginSpoofRedacted => 'Подделка спуфа'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a53ed02..49ded61 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7,12 +7,12 @@ "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Другие способы входа", "loginTermsIntro": "Продолжая, вы соглашаетесь с \n", - "loginTermsLink": "пользовательскими соглашениями", - "loginTermsOfUse": "Условия использования", + "loginTermsLink": "Условия использования «Komet»", + "loginTermsOfUse": "Условия использования «Komet»", "loginConfirmPhoneTitle": "Это правильный номер?", "loginEdit": "Изменить", "loginDone": "Готово", - "loginReadTermsNotification": "Сначала прочитайте условия использования", + "loginReadTermsNotification": "Сначала прочитайте условия использования «Komet»", "loginSpoofRedacted": "Подделка спуфа", "loginProxy": "Прокси", "loginSignInWithQr": "По QR code", diff --git a/pubspec.lock b/pubspec.lock index 62a257f..acfb34b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -530,6 +530,14 @@ packages: description: flutter source: sdk version: "0.0.0" + smart_auth: + dependency: "direct main" + description: + name: smart_auth + sha256: a536423c50d71e9a311d16027346634d0deadd07dafc5b5606b46719cf6fb2b6 + url: "https://pub.dev" + source: hosted + version: "3.2.0" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0341c9a..7af58b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: shared_preferences: ^2.5.4 package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 + smart_auth: ^3.2.0 dev_dependencies: flutter_test: From e320487950af0e7894d58bdb4b18659fb1ee142e Mon Sep 17 00:00:00 2001 From: Sergej Nekrasov <130226127+SergejWinston@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:56:29 +0700 Subject: [PATCH 059/323] Update gradle.properties --- android/gradle.properties | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index 398cde8..1cc1815 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,8 +1,4 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true kotlin.incremental=false -dev.steenbakker.mobile_scanner.useUnbundled=true -systemProp.socksProxyHost=127.0.0.1 -systemProp.socksProxyPort=10808 -systemProp.https.proxyHost=127.0.0.1 -systemProp.https.proxyPort=10808 +dev.steenbakker.mobile_scanner.useUnbundled=true \ No newline at end of file From ac4b645be57c848e78f7bfc6cc645beba2855e97 Mon Sep 17 00:00:00 2001 From: Sergej Nekrasov <130226127+SergejWinston@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:10:18 +0000 Subject: [PATCH 060/323] Revert "Update gradle.properties" This reverts commit e320487950af0e7894d58bdb4b18659fb1ee142e. --- android/gradle.properties | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index 1cc1815..398cde8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,8 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true kotlin.incremental=false -dev.steenbakker.mobile_scanner.useUnbundled=true \ No newline at end of file +dev.steenbakker.mobile_scanner.useUnbundled=true +systemProp.socksProxyHost=127.0.0.1 +systemProp.socksProxyPort=10808 +systemProp.https.proxyHost=127.0.0.1 +systemProp.https.proxyPort=10808 From 24f4c6e1e9c3c80e0d3a88ac1e3ce2fa28e6f8af Mon Sep 17 00:00:00 2001 From: Sergej Nekrasov <130226127+SergejWinston@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:10:31 +0000 Subject: [PATCH 061/323] Revert "feat(auth): integrate SmartAuth for SMS code auto-fill and update localization strings" This reverts commit 37e320a52b0476946df15d2ebc4659d714c90d8c. --- android/gradle.properties | 4 -- .../auth/code_confirmation_screen.dart | 65 ++----------------- lib/frontend/screens/auth/login_screen.dart | 9 +++ lib/l10n/app_en.arb | 15 +++-- lib/l10n/app_localizations.dart | 20 ++++-- lib/l10n/app_localizations_en.dart | 18 ++--- lib/l10n/app_localizations_ru.dart | 9 ++- lib/l10n/app_ru.arb | 6 +- pubspec.lock | 8 --- pubspec.yaml | 1 - 10 files changed, 54 insertions(+), 101 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index 398cde8..492b2f1 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,7 +2,3 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true kotlin.incremental=false dev.steenbakker.mobile_scanner.useUnbundled=true -systemProp.socksProxyHost=127.0.0.1 -systemProp.socksProxyPort=10808 -systemProp.https.proxyHost=127.0.0.1 -systemProp.https.proxyPort=10808 diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 21a2194..3840751 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -1,10 +1,8 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:smart_auth/smart_auth.dart'; import '../chats/chat_list_screen.dart'; import 'password_2fa_screen.dart'; import '../../../main.dart'; @@ -28,11 +26,9 @@ class _CodeConfirmationScreenState extends State with TickerProviderStateMixin { final TextEditingController _codeController = TextEditingController(); final FocusNode _focusNode = FocusNode(); - final SmartAuth _smartAuth = SmartAuth.instance; int _timerSeconds = 30; Timer? _timer; Timer? _errorTimer; - bool _isListeningSmsConsent = false; String? _errorMessage; late AnimationController _shakeController; @@ -45,7 +41,6 @@ class _CodeConfirmationScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); - _startSmsCodeListener(); _shakeController = AnimationController( vsync: this, @@ -61,63 +56,10 @@ class _CodeConfirmationScreenState extends State ]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear)); } - Future _startSmsCodeListener() async { - if (defaultTargetPlatform != TargetPlatform.android) return; - if (_isListeningSmsConsent) return; - - _isListeningSmsConsent = true; - final result = await _smartAuth.getSmsWithUserConsentApi(matcher: r'\d{6}'); - _isListeningSmsConsent = false; - - if (!mounted || !result.hasData) return; - - final receivedCode = result.data?.code; - if (receivedCode == null || receivedCode.isEmpty) return; - - _applyAutoFillCode(receivedCode); - } - - Future _restartSmsCodeListener() async { - if (defaultTargetPlatform != TargetPlatform.android) return; - await _smartAuth.removeUserConsentApiListener(); - _isListeningSmsConsent = false; - await _startSmsCodeListener(); - } - - void _handleCodeChanged(String value) { - setState(() { - if (_errorMessage != null) { - _errorMessage = null; - } - }); - if (value.length == 6) { - _verifyCode(); - } - } - - void _applyAutoFillCode(String rawCode) { - final digitsOnly = rawCode.replaceAll(RegExp(r'\D'), ''); - if (digitsOnly.isEmpty) return; - - final nextCode = digitsOnly.length <= 6 - ? digitsOnly - : digitsOnly.substring(0, 6); - - if (_codeController.text == nextCode) return; - - _codeController.value = TextEditingValue( - text: nextCode, - selection: TextSelection.collapsed(offset: nextCode.length), - ); - - _handleCodeChanged(nextCode); - } - @override void dispose() { _timer?.cancel(); _errorTimer?.cancel(); - unawaited(_smartAuth.removeUserConsentApiListener()); _shakeController.dispose(); _codeController.dispose(); _focusNode.dispose(); @@ -150,7 +92,6 @@ class _CodeConfirmationScreenState extends State void _resendCode() { if (_timerSeconds == 0) { _startTimer(); - unawaited(_restartSmsCodeListener()); } } @@ -265,7 +206,11 @@ class _CodeConfirmationScreenState extends State FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6), ], - onChanged: _handleCodeChanged, + onChanged: (value) { + if (hasError) setState(() => _errorMessage = null); + setState(() {}); + if (value.length == 6) _verifyCode(); + }, ), ), ), diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 08f74e8..579f250 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -795,6 +795,15 @@ class _LoginScreenState extends State { height: 1.4, ), children: [ + TextSpan( + text: l10n.loginTermsIntro, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), TextSpan( text: l10n.loginTermsLink, style: GoogleFonts.inter( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7ec3f63..aa424d8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,22 +1,23 @@ { "@@locale": "en", - "loginTitle": "Log in to Komet", + "loginTitle": "Sign in to Komet", "loginSubtitle": "Check your country code and enter your\nphone number.", "loginCountry": "Country", "loginPhoneNumber": "Phone number", "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Other sign-in methods", - "loginTermsLink": "Terms of Use «Komet»", - "loginTermsOfUse": "Terms of Use «Komet»", + "loginTermsIntro": "By continuing, you agree to \n", + "loginTermsLink": "the terms of use", + "loginTermsOfUse": "Terms of use", "loginConfirmPhoneTitle": "Is this the correct number?", "loginEdit": "Change", "loginDone": "Done", - "loginReadTermsNotification": "Please read the Terms of Use «Komet»", + "loginReadTermsNotification": "Please read the terms of use first", "loginSpoofRedacted": "Spoof redaction", "loginProxy": "Proxy", - "loginSignInWithQr": "Log in with QR code", - "loginSignInWithToken": "Log in with token", - "loginSignInWithSessionFile": "Log in with session file", + "loginSignInWithQr": "Sign in with QR code", + "loginSignInWithToken": "Sign in with token", + "loginSignInWithSessionFile": "Sign in with session file", "loginLanguage": "Language", "languageNameRu": "Русский", "languageNameEn": "English", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 67816f8..b0614d5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -101,7 +101,7 @@ abstract class AppLocalizations { /// No description provided for @loginTitle. /// /// In en, this message translates to: - /// **'Log in to Komet'** + /// **'Sign in to Komet'** String get loginTitle; /// No description provided for @loginSubtitle. @@ -134,16 +134,22 @@ abstract class AppLocalizations { /// **'Other sign-in methods'** String get loginOtherSignInMethods; + /// No description provided for @loginTermsIntro. + /// + /// In en, this message translates to: + /// **'By continuing, you agree to \n'** + String get loginTermsIntro; + /// No description provided for @loginTermsLink. /// /// In en, this message translates to: - /// **'Terms of Use «Komet»'** + /// **'the terms of use'** String get loginTermsLink; /// No description provided for @loginTermsOfUse. /// /// In en, this message translates to: - /// **'Terms of Use «Komet»'** + /// **'Terms of use'** String get loginTermsOfUse; /// No description provided for @loginConfirmPhoneTitle. @@ -167,7 +173,7 @@ abstract class AppLocalizations { /// No description provided for @loginReadTermsNotification. /// /// In en, this message translates to: - /// **'Please read the Terms of Use «Komet»'** + /// **'Please read the terms of use first'** String get loginReadTermsNotification; /// No description provided for @loginSpoofRedacted. @@ -185,19 +191,19 @@ abstract class AppLocalizations { /// No description provided for @loginSignInWithQr. /// /// In en, this message translates to: - /// **'Log in with QR code'** + /// **'Sign in with QR code'** String get loginSignInWithQr; /// No description provided for @loginSignInWithToken. /// /// In en, this message translates to: - /// **'Log in with token'** + /// **'Sign in with token'** String get loginSignInWithToken; /// No description provided for @loginSignInWithSessionFile. /// /// In en, this message translates to: - /// **'Log in with session file'** + /// **'Sign in with session file'** String get loginSignInWithSessionFile; /// No description provided for @loginLanguage. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3f6e67e..5ddbe9b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override - String get loginTitle => 'Log in to Komet'; + String get loginTitle => 'Sign in to Komet'; @override String get loginSubtitle => @@ -28,10 +28,13 @@ class AppLocalizationsEn extends AppLocalizations { String get loginOtherSignInMethods => 'Other sign-in methods'; @override - String get loginTermsLink => 'Terms of Use «Komet»'; + String get loginTermsIntro => 'By continuing, you agree to \n'; @override - String get loginTermsOfUse => 'Terms of Use «Komet»'; + String get loginTermsLink => 'the terms of use'; + + @override + String get loginTermsOfUse => 'Terms of use'; @override String get loginConfirmPhoneTitle => 'Is this the correct number?'; @@ -43,8 +46,7 @@ class AppLocalizationsEn extends AppLocalizations { String get loginDone => 'Done'; @override - String get loginReadTermsNotification => - 'Please read the Terms of Use «Komet»'; + String get loginReadTermsNotification => 'Please read the terms of use first'; @override String get loginSpoofRedacted => 'Spoof redaction'; @@ -53,13 +55,13 @@ class AppLocalizationsEn extends AppLocalizations { String get loginProxy => 'Proxy'; @override - String get loginSignInWithQr => 'Log in with QR code'; + String get loginSignInWithQr => 'Sign in with QR code'; @override - String get loginSignInWithToken => 'Log in with token'; + String get loginSignInWithToken => 'Sign in with token'; @override - String get loginSignInWithSessionFile => 'Log in with session file'; + String get loginSignInWithSessionFile => 'Sign in with session file'; @override String get loginLanguage => 'Language'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 63d6d7d..e63e96d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -28,10 +28,13 @@ class AppLocalizationsRu extends AppLocalizations { String get loginOtherSignInMethods => 'Другие способы входа'; @override - String get loginTermsLink => 'Условия использования «Komet»'; + String get loginTermsIntro => 'Продолжая, вы соглашаетесь с \n'; @override - String get loginTermsOfUse => 'Условия использования «Komet»'; + String get loginTermsLink => 'пользовательскими соглашениями'; + + @override + String get loginTermsOfUse => 'Условия использования'; @override String get loginConfirmPhoneTitle => 'Это правильный номер?'; @@ -44,7 +47,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginReadTermsNotification => - 'Сначала прочитайте условия использования «Komet»'; + 'Сначала прочитайте условия использования'; @override String get loginSpoofRedacted => 'Подделка спуфа'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 49ded61..a53ed02 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7,12 +7,12 @@ "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Другие способы входа", "loginTermsIntro": "Продолжая, вы соглашаетесь с \n", - "loginTermsLink": "Условия использования «Komet»", - "loginTermsOfUse": "Условия использования «Komet»", + "loginTermsLink": "пользовательскими соглашениями", + "loginTermsOfUse": "Условия использования", "loginConfirmPhoneTitle": "Это правильный номер?", "loginEdit": "Изменить", "loginDone": "Готово", - "loginReadTermsNotification": "Сначала прочитайте условия использования «Komet»", + "loginReadTermsNotification": "Сначала прочитайте условия использования", "loginSpoofRedacted": "Подделка спуфа", "loginProxy": "Прокси", "loginSignInWithQr": "По QR code", diff --git a/pubspec.lock b/pubspec.lock index acfb34b..62a257f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -530,14 +530,6 @@ packages: description: flutter source: sdk version: "0.0.0" - smart_auth: - dependency: "direct main" - description: - name: smart_auth - sha256: a536423c50d71e9a311d16027346634d0deadd07dafc5b5606b46719cf6fb2b6 - url: "https://pub.dev" - source: hosted - version: "3.2.0" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7af58b7..0341c9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,6 @@ dependencies: shared_preferences: ^2.5.4 package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 - smart_auth: ^3.2.0 dev_dependencies: flutter_test: From 9b5cb53961db9889da5de18ac8b04cdfa8a913eb Mon Sep 17 00:00:00 2001 From: Sergej Nekrasov <130226127+SergejWinston@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:11:35 +0700 Subject: [PATCH 062/323] Update .gitignore revent... --- .gitignore | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 0d6d7ea..14601b5 100644 --- a/.gitignore +++ b/.gitignore @@ -125,7 +125,4 @@ app.*.symbols !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -!/dev/ci/**/Gemfile.lock - - -AGENTS.md \ No newline at end of file +!/dev/ci/**/Gemfile.lock \ No newline at end of file From 035c57b9c5248372ded3d5cd3aec2dd857bfd776 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 7 Apr 2026 11:16:30 +0300 Subject: [PATCH 063/323] chore(dependencies): update device_info_plus to version 12.3.0 and adjust related project files --- ios/Podfile.lock | 62 ++++++++++ ios/Runner.xcodeproj/project.pbxproj | 112 ++++++++++++++++++ .../contents.xcworkspacedata | 3 + pubspec.lock | 4 +- pubspec.yaml | 2 +- 5 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 ios/Podfile.lock diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..308466c --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,62 @@ +PODS: + - device_info_plus (0.0.1): + - Flutter + - Flutter (1.0.0) + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + - flutter_timezone (0.0.1): + - Flutter + - mobile_scanner (7.0.0): + - Flutter + - FlutterMacOS + - package_info_plus (0.4.5): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - Flutter (from `Flutter`) + - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + +EXTERNAL SOURCES: + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + Flutter: + :path: Flutter + flutter_secure_storage_darwin: + :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + flutter_timezone: + :path: ".symlinks/plugins/flutter_timezone/ios" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/darwin" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + +SPEC CHECKSUMS: + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d + flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282 + mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 765a142..5b0a064 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,10 +10,12 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + E835CC948F2F948DC3BBF405 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,12 +44,19 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 28C4A8DBB53D5AC72DDB74A4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 366C243BBAFE3F87FBDF57A1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 83CE6DC2BACBB6F59AEA57B6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,13 +64,23 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 2594AD7A0161CA1A66191F05 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E835CC948F2F948DC3BBF405 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,6 +95,15 @@ path = RunnerTests; sourceTree = ""; }; + 3E066142FD515067AADAC58D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */, + 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +122,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + C4C44F130EEB07A77FD05026 /* Pods */, + 3E066142FD515067AADAC58D /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +151,20 @@ path = Runner; sourceTree = ""; }; + C4C44F130EEB07A77FD05026 /* Pods */ = { + isa = PBXGroup; + children = ( + 83CE6DC2BACBB6F59AEA57B6 /* Pods-Runner.debug.xcconfig */, + 366C243BBAFE3F87FBDF57A1 /* Pods-Runner.release.xcconfig */, + 28C4A8DBB53D5AC72DDB74A4 /* Pods-Runner.profile.xcconfig */, + 1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */, + 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */, + 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 942752100DC29827FE94D2B2 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 2594AD7A0161CA1A66191F05 /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 8FBC28ABA859CAFFB7CFEFA3 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 122816C3E1F98387D8AA58D3 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -222,6 +270,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 122816C3E1F98387D8AA58D3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -238,6 +303,50 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 8FBC28ABA859CAFFB7CFEFA3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 942752100DC29827FE94D2B2 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -378,6 +487,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +505,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +521,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/pubspec.lock b/pubspec.lock index 62a257f..1f7d572 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" url: "https://pub.dev" source: hosted - version: "12.4.0" + version: "12.3.0" device_info_plus_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0341c9a..f681c96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: dart_lz4: ^1.0.0 msgpack_dart: ^1.0.1 logger: ^2.6.2 - device_info_plus: ^12.3.0 + device_info_plus: 12.3.0 flutter_timezone: ^5.0.1 timezone: ^0.11.0 flutter_secure_storage: ^10.0.0 From 1f9ead1b0f058e4b2559019db26f78242acbded6 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 7 Apr 2026 13:36:30 +0300 Subject: [PATCH 064/323] feat(auth): server endpoint in bottom sheet on login --- lib/backend/api.dart | 3 +- lib/core/config/config.dart | 22 +- lib/frontend/screens/auth/login_screen.dart | 39 +++- .../screens/auth/server_settings_sheet.dart | 215 ++++++++++++++++++ lib/l10n/app_en.arb | 9 + lib/l10n/app_localizations.dart | 54 +++++ lib/l10n/app_localizations_en.dart | 27 +++ lib/l10n/app_localizations_ru.dart | 28 +++ lib/l10n/app_ru.arb | 9 + pubspec.lock | 16 +- 10 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 lib/frontend/screens/auth/server_settings_sheet.dart diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 402cf92..7751155 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -70,7 +70,8 @@ class Api { }); try { - await _connection.connect(ServerConfig.host, ServerConfig.port); + final endpoint = await ServerConfig.loadEndpoint(); + await _connection.connect(endpoint.host, endpoint.port); } catch (e) { logger.e('Не удалось подключиться: $e'); _cleanup(); diff --git a/lib/core/config/config.dart b/lib/core/config/config.dart index 4ef18b3..c0c97cc 100644 --- a/lib/core/config/config.dart +++ b/lib/core/config/config.dart @@ -1,7 +1,25 @@ +import 'package:shared_preferences/shared_preferences.dart'; + abstract class ServerConfig { - static const String host = 'api.oneme.ru'; - static const int port = 443; + static const String defaultHost = 'api.oneme.ru'; + static const int defaultPort = 443; + static const String prefHostKey = 'server_host_override'; + static const String prefPortKey = 'server_port_override'; static const Duration pingInterval = Duration(seconds: 30); static const Duration requestTimeout = Duration(seconds: 30); static const int maxReconnectAttempts = 50; + + static Future<({String host, int port})> loadEndpoint() async { + final prefs = await SharedPreferences.getInstance(); + final rawHost = prefs.getString(prefHostKey); + final rawPort = prefs.getInt(prefPortKey); + final host = (rawHost != null && rawHost.trim().isNotEmpty) + ? rawHost.trim() + : defaultHost; + var port = defaultPort; + if (rawPort != null && rawPort >= 1 && rawPort <= 65535) { + port = rawPort; + } + return (host: host, port: port); + } } diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 579f250..a531b1d 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -10,6 +10,7 @@ import 'package:komet/l10n/terms_of_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; +import 'server_settings_sheet.dart'; import 'spoff_redacted_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../../main.dart'; @@ -448,6 +449,23 @@ class _LoginScreenState extends State { _showPhoneConfirmationDialog(_phoneController.text); } + void _showServerSettingsSheet(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) { + return SafeArea( + child: const ServerSettingsSheet(), + ); + }, + ); + } + void _showSecurityOptions(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; @@ -457,7 +475,7 @@ class _LoginScreenState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), - builder: (context) { + builder: (sheetContext) { return SafeArea( child: Padding( padding: const EdgeInsets.symmetric( @@ -478,7 +496,7 @@ class _LoginScreenState extends State { ), ), onTap: () { - Navigator.pop(context); + Navigator.pop(sheetContext); Navigator.push( context, MaterialPageRoute( @@ -498,7 +516,22 @@ class _LoginScreenState extends State { ), ), onTap: () { - Navigator.pop(context); + Navigator.pop(sheetContext); + }, + ), + ListTile( + leading: Icon(Symbols.dns, color: cs.onSurface), + title: Text( + l10n.loginChangeServer, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + _showServerSettingsSheet(context); }, ), ], diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart new file mode 100644 index 0000000..9c85651 --- /dev/null +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/backend/api.dart'; +import 'package:komet/core/config/config.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; + +class ServerSettingsSheet extends StatefulWidget { + const ServerSettingsSheet({super.key}); + + @override + State createState() => _ServerSettingsSheetState(); +} + +class _ServerSettingsSheetState extends State { + final TextEditingController _hostController = TextEditingController( + text: ServerConfig.defaultHost, + ); + final TextEditingController _portController = TextEditingController( + text: '${ServerConfig.defaultPort}', + ); + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final endpoint = await ServerConfig.loadEndpoint(); + if (!mounted) return; + setState(() { + _hostController.text = endpoint.host; + _portController.text = '${endpoint.port}'; + }); + } + + Future _apply(AppLocalizations l10n) async { + final host = _hostController.text.trim(); + final port = int.tryParse(_portController.text.trim()); + if (host.isEmpty || port == null || port < 1 || port > 65535) { + showCustomNotification(context, l10n.serverInvalidHostOrPort); + return; + } + setState(() => _busy = true); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(ServerConfig.prefHostKey, host); + await prefs.setInt(ServerConfig.prefPortKey, port); + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.serverSettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _resetToDefault(AppLocalizations l10n) async { + setState(() => _busy = true); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(ServerConfig.prefHostKey); + await prefs.remove(ServerConfig.prefPortKey); + _hostController.text = ServerConfig.defaultHost; + _portController.text = '${ServerConfig.defaultPort}'; + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.serverSettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + l10n.serverSettingsTitle, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + _buildTextField( + controller: _hostController, + label: l10n.serverHostLabel, + hintText: ServerConfig.defaultHost, + cs: cs, + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _portController, + label: l10n.serverPortLabel, + hintText: '${ServerConfig.defaultPort}', + cs: cs, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _busy ? null : () => _apply(l10n), + child: Text(l10n.serverApply), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: _busy ? null : () => _resetToDefault(l10n), + child: Text(l10n.serverUseDefault), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required ColorScheme cs, + String? hintText, + TextInputType? keyboardType, + List? inputFormatters, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + enabled: !_busy, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: GoogleFonts.inter( + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 15, + ), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aa424d8..57a5c1c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15,6 +15,15 @@ "loginReadTermsNotification": "Please read the terms of use first", "loginSpoofRedacted": "Spoof redaction", "loginProxy": "Proxy", + "loginChangeServer": "Change server", + "serverSettingsTitle": "Server", + "serverHostLabel": "Host", + "serverPortLabel": "Port", + "serverApply": "Apply and reconnect", + "serverUseDefault": "Reset to default", + "serverInvalidHostOrPort": "Enter a valid host and port (1–65535)", + "serverSettingsSaved": "Server settings applied", + "serverReconnectFailed": "Could not connect to the server", "loginSignInWithQr": "Sign in with QR code", "loginSignInWithToken": "Sign in with token", "loginSignInWithSessionFile": "Sign in with session file", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b0614d5..605d726 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -188,6 +188,60 @@ abstract class AppLocalizations { /// **'Proxy'** String get loginProxy; + /// No description provided for @loginChangeServer. + /// + /// In en, this message translates to: + /// **'Change server'** + String get loginChangeServer; + + /// No description provided for @serverSettingsTitle. + /// + /// In en, this message translates to: + /// **'Server'** + String get serverSettingsTitle; + + /// No description provided for @serverHostLabel. + /// + /// In en, this message translates to: + /// **'Host'** + String get serverHostLabel; + + /// No description provided for @serverPortLabel. + /// + /// In en, this message translates to: + /// **'Port'** + String get serverPortLabel; + + /// No description provided for @serverApply. + /// + /// In en, this message translates to: + /// **'Apply and reconnect'** + String get serverApply; + + /// No description provided for @serverUseDefault. + /// + /// In en, this message translates to: + /// **'Reset to default'** + String get serverUseDefault; + + /// No description provided for @serverInvalidHostOrPort. + /// + /// In en, this message translates to: + /// **'Enter a valid host and port (1–65535)'** + String get serverInvalidHostOrPort; + + /// No description provided for @serverSettingsSaved. + /// + /// In en, this message translates to: + /// **'Server settings applied'** + String get serverSettingsSaved; + + /// No description provided for @serverReconnectFailed. + /// + /// In en, this message translates to: + /// **'Could not connect to the server'** + String get serverReconnectFailed; + /// No description provided for @loginSignInWithQr. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5ddbe9b..32b6401 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -54,6 +54,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loginProxy => 'Proxy'; + @override + String get loginChangeServer => 'Change server'; + + @override + String get serverSettingsTitle => 'Server'; + + @override + String get serverHostLabel => 'Host'; + + @override + String get serverPortLabel => 'Port'; + + @override + String get serverApply => 'Apply and reconnect'; + + @override + String get serverUseDefault => 'Reset to default'; + + @override + String get serverInvalidHostOrPort => 'Enter a valid host and port (1–65535)'; + + @override + String get serverSettingsSaved => 'Server settings applied'; + + @override + String get serverReconnectFailed => 'Could not connect to the server'; + @override String get loginSignInWithQr => 'Sign in with QR code'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e63e96d..f1f7049 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -55,6 +55,34 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginProxy => 'Прокси'; + @override + String get loginChangeServer => 'Смена сервера'; + + @override + String get serverSettingsTitle => 'Сервер'; + + @override + String get serverHostLabel => 'Хост'; + + @override + String get serverPortLabel => 'Порт'; + + @override + String get serverApply => 'Применить и переподключиться'; + + @override + String get serverUseDefault => 'Сбросить к умолчанию'; + + @override + String get serverInvalidHostOrPort => + 'Укажите корректный хост и порт (1–65535)'; + + @override + String get serverSettingsSaved => 'Настройки сервера применены'; + + @override + String get serverReconnectFailed => 'Не удалось подключиться к серверу'; + @override String get loginSignInWithQr => 'По QR code'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a53ed02..fdf86aa 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -15,6 +15,15 @@ "loginReadTermsNotification": "Сначала прочитайте условия использования", "loginSpoofRedacted": "Подделка спуфа", "loginProxy": "Прокси", + "loginChangeServer": "Смена сервера", + "serverSettingsTitle": "Сервер", + "serverHostLabel": "Хост", + "serverPortLabel": "Порт", + "serverApply": "Применить и переподключиться", + "serverUseDefault": "Сбросить к умолчанию", + "serverInvalidHostOrPort": "Укажите корректный хост и порт (1–65535)", + "serverSettingsSaved": "Настройки сервера применены", + "serverReconnectFailed": "Не удалось подключиться к серверу", "loginSignInWithQr": "По QR code", "loginSignInWithToken": "По токену", "loginSignInWithSessionFile": "По файлу сессии", diff --git a/pubspec.lock b/pubspec.lock index 1f7d572..b09c9ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" timezone: dependency: "direct main" description: From ffece278c8a6ea7b2fb55d84ae6113df03c98dc7 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 7 Apr 2026 13:50:34 +0300 Subject: [PATCH 065/323] fix(auth): per-country phone mask hint and Indonesia digit count --- lib/core/config/countries.dart | 4 ++-- lib/frontend/screens/auth/login_screen.dart | 7 ++++++- lib/l10n/app_en.arb | 1 - lib/l10n/app_localizations.dart | 6 ------ lib/l10n/app_localizations_en.dart | 3 --- lib/l10n/app_localizations_ru.dart | 3 --- lib/l10n/app_ru.arb | 1 - 7 files changed, 8 insertions(+), 17 deletions(-) diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart index e87262e..e4120c3 100644 --- a/lib/core/config/countries.dart +++ b/lib/core/config/countries.dart @@ -166,7 +166,7 @@ const String _countriesEnJson = '''[ {"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Hungary", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Iceland", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, {"id": 356, "alpha2": "in", "alpha3": "ind", "name": "India", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]}, -{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Iran, Islamic Republic of", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Iraq", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ireland", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, @@ -342,7 +342,7 @@ const String _countriesRuJson = '''[ {"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Зимбабве", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Израиль", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, {"id": 356, "alpha2": "in", "alpha3": "ind", "name": "Индия", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]}, -{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Иордания", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Ирак", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Иран", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index a531b1d..e182159 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -97,6 +97,10 @@ class _LoginScreenState extends State { return lang == 'ru' ? country.ru : country.en; } + String _phoneMaskHint(CountryName country) { + return country.phoneMask.replaceAll('#', '0'); + } + void _showLanguagePicker() { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; @@ -740,6 +744,7 @@ class _LoginScreenState extends State { const SizedBox(width: 12), Expanded( child: TextField( + key: ValueKey(_selectedCountry.code), controller: _phoneController, keyboardType: TextInputType.phone, inputFormatters: [ @@ -752,7 +757,7 @@ class _LoginScreenState extends State { fontWeight: FontWeight.w400, ), decoration: InputDecoration( - hintText: l10n.loginPhoneHint, + hintText: _phoneMaskHint(_selectedCountry), hintStyle: TextStyle( color: cs.outline, fontSize: 15, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 57a5c1c..3d832a0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4,7 +4,6 @@ "loginSubtitle": "Check your country code and enter your\nphone number.", "loginCountry": "Country", "loginPhoneNumber": "Phone number", - "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Other sign-in methods", "loginTermsIntro": "By continuing, you agree to \n", "loginTermsLink": "the terms of use", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 605d726..05cc593 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -122,12 +122,6 @@ abstract class AppLocalizations { /// **'Phone number'** String get loginPhoneNumber; - /// No description provided for @loginPhoneHint. - /// - /// In en, this message translates to: - /// **'(000) 000-00-00'** - String get loginPhoneHint; - /// No description provided for @loginOtherSignInMethods. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 32b6401..df7fda7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -21,9 +21,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loginPhoneNumber => 'Phone number'; - @override - String get loginPhoneHint => '(000) 000-00-00'; - @override String get loginOtherSignInMethods => 'Other sign-in methods'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index f1f7049..6a92039 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -21,9 +21,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginPhoneNumber => 'Номер телефона'; - @override - String get loginPhoneHint => '(000) 000-00-00'; - @override String get loginOtherSignInMethods => 'Другие способы входа'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fdf86aa..1d92905 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -4,7 +4,6 @@ "loginSubtitle": "Проверьте код страны и введите свой\nномер телефона.", "loginCountry": "Страна", "loginPhoneNumber": "Номер телефона", - "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Другие способы входа", "loginTermsIntro": "Продолжая, вы соглашаетесь с \n", "loginTermsLink": "пользовательскими соглашениями", From c4c03983eb1f5c8cfc44cda546ec0129318d7b30 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 7 Apr 2026 19:27:05 +0300 Subject: [PATCH 066/323] fix(auth): strip formatting from phone before requestCode --- lib/backend/modules/account.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 52fe39d..d86108f 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -10,6 +10,11 @@ import 'chats.dart'; import 'contacts.dart'; import 'folders.dart'; +String _normalizeAuthPhone(String phone) { + final digits = phone.replaceAll(RegExp(r'\D'), ''); + return '+$digits'; +} + class PrivacyConfig { final String searchByPhone; final String incomingCall; @@ -976,13 +981,15 @@ class AccountModule { ) async { _ensureOnline(); + final normalizedPhone = _normalizeAuthPhone(phone); + final payload = { - 'phone': phone, + 'phone': normalizedPhone, 'type': type.value, 'language': language, }; - logger.i('Запрос OTP-кода: phone=$phone type=${type.value}'); + logger.i('Запрос OTP-кода: phone=$normalizedPhone type=${type.value}'); final packet = await _api.sendRequest(Opcode.authRequest, payload); From 21dbff86d519c08b417968485deebb233c5c43a8 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 9 Apr 2026 14:48:31 +0300 Subject: [PATCH 067/323] feat(network): SOCKS5 and HTTP(S) proxy support --- lib/core/config/proxy_config.dart | 77 ++++ lib/core/transport/connection.dart | 43 ++- lib/core/transport/proxy_connector.dart | 365 ++++++++++++++++++ lib/frontend/screens/auth/login_screen.dart | 19 + .../screens/auth/proxy_settings_sheet.dart | 306 +++++++++++++++ .../screens/profile/settings_tab.dart | 23 ++ lib/l10n/app_en.arb | 15 +- lib/l10n/app_localizations.dart | 72 ++++ lib/l10n/app_localizations_en.dart | 37 ++ lib/l10n/app_localizations_ru.dart | 37 ++ lib/l10n/app_ru.arb | 15 +- 11 files changed, 999 insertions(+), 10 deletions(-) create mode 100644 lib/core/config/proxy_config.dart create mode 100644 lib/core/transport/proxy_connector.dart create mode 100644 lib/frontend/screens/auth/proxy_settings_sheet.dart diff --git a/lib/core/config/proxy_config.dart b/lib/core/config/proxy_config.dart new file mode 100644 index 0000000..3428fd8 --- /dev/null +++ b/lib/core/config/proxy_config.dart @@ -0,0 +1,77 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +enum ProxyType { none, socks5, httpConnect } + +class ProxySettings { + final ProxyType type; + final String host; + final int port; + final String? username; + final String? password; + + const ProxySettings({ + this.type = ProxyType.none, + this.host = '', + this.port = 1080, + this.username, + this.password, + }); + + bool get isEnabled => type != ProxyType.none && host.isNotEmpty; + + bool get hasCredentials => + username != null && + username!.isNotEmpty && + password != null && + password!.isNotEmpty; +} + +abstract class ProxyConfig { + static const String _prefType = 'proxy_type'; + static const String _prefHost = 'proxy_host'; + static const String _prefPort = 'proxy_port'; + static const String _prefUsername = 'proxy_username'; + static const String _prefPassword = 'proxy_password'; + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final typeIndex = prefs.getInt(_prefType) ?? 0; + final host = prefs.getString(_prefHost) ?? ''; + final port = prefs.getInt(_prefPort) ?? 1080; + final username = prefs.getString(_prefUsername); + final password = prefs.getString(_prefPassword); + return ProxySettings( + type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], + host: host, + port: port, + username: username, + password: password, + ); + } + + static Future save(ProxySettings settings) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_prefType, settings.type.index); + await prefs.setString(_prefHost, settings.host); + await prefs.setInt(_prefPort, settings.port); + if (settings.username != null) { + await prefs.setString(_prefUsername, settings.username!); + } else { + await prefs.remove(_prefUsername); + } + if (settings.password != null) { + await prefs.setString(_prefPassword, settings.password!); + } else { + await prefs.remove(_prefPassword); + } + } + + static Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefType); + await prefs.remove(_prefHost); + await prefs.remove(_prefPort); + await prefs.remove(_prefUsername); + await prefs.remove(_prefPassword); + } +} diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index 16c4444..15c3a53 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -2,14 +2,17 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; +import '../config/proxy_config.dart'; import '../utils/logger.dart'; +import 'proxy_connector.dart'; enum SocketState { disconnected, connecting, connected } /// Обёртка над TCP + TLS сокетом. /// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. class Connection { - SecureSocket? _socket; + RawSecureSocket? _socket; + StreamSubscription? _subscription; SocketState _state = SocketState.disconnected; final _dataController = StreamController.broadcast(); @@ -31,17 +34,39 @@ class Connection { _setState(SocketState.connecting); try { - final socket = await Socket.connect(host, port); - _socket = await SecureSocket.secure( - socket, + final proxySettings = await ProxyConfig.load(); + RawSocket rawSocket; + + if (proxySettings.isEnabled) { + final connector = ProxyConnector(proxySettings); + rawSocket = await connector.connect(host, port); + logger.i('Подключено через прокси ${proxySettings.type.name}'); + } else { + rawSocket = await RawSocket.connect(host, port); + } + + _socket = await RawSecureSocket.secure( + rawSocket, + host: host, onBadCertificate: (_) => true, ); _setState(SocketState.connected); logger.i('Подключено к $host:$port'); - _socket!.listen( - (data) => _dataController.add(Uint8List.fromList(data)), + _subscription = _socket!.listen( + (event) { + if (event == RawSocketEvent.read) { + final data = _socket?.read(); + if (data != null) { + _dataController.add(data); + } + } else if (event == RawSocketEvent.readClosed || + event == RawSocketEvent.closed) { + logger.w('Сокет закрыт сервером'); + disconnect(); + } + }, onError: (Object error) { logger.e('Ошибка сокета: $error'); disconnect(); @@ -62,16 +87,18 @@ class Connection { if (_socket == null || !isConnected) { throw StateError('Нельзя писать: сокет не подключён'); } - _socket!.add(data); + _socket!.write(data); } Future disconnect() async { + _subscription?.cancel(); + _subscription = null; final socket = _socket; _socket = null; if (socket != null) { try { - socket.destroy(); + socket.close(); } catch (_) {} } diff --git a/lib/core/transport/proxy_connector.dart b/lib/core/transport/proxy_connector.dart new file mode 100644 index 0000000..aaac29b --- /dev/null +++ b/lib/core/transport/proxy_connector.dart @@ -0,0 +1,365 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../config/proxy_config.dart'; +import '../utils/logger.dart'; + +/// Устанавливает TCP-соединение через SOCKS5 или HTTP CONNECT прокси. +/// Возвращает [RawSocket], который никогда не слушался — +/// его можно передать в [RawSecureSocket.secure]. +class ProxyConnector { + final ProxySettings settings; + + ProxyConnector(this.settings); + + Future connect(String targetHost, int targetPort) async { + switch (settings.type) { + case ProxyType.socks5: + return _connectSocks5(targetHost, targetPort); + case ProxyType.httpConnect: + return _connectHttpConnect(targetHost, targetPort); + case ProxyType.none: + return RawSocket.connect(targetHost, targetPort); + } + } + + // ── SOCKS5 (RFC 1928) ────────────────────────────────────────────────── + + Future _connectSocks5(String targetHost, int targetPort) async { + final proxySocket = await RawSocket.connect(settings.host, settings.port); + logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}'); + + final io = _RawSocketIO(proxySocket); + try { + // 1. Greeting + final useAuth = settings.hasCredentials; + if (useAuth) { + await io.write([0x05, 0x02, 0x00, 0x02]); + } else { + await io.write([0x05, 0x01, 0x00]); + } + + var response = await io.readExact(2); + if (response[0] != 0x05) { + throw SocketException( + 'SOCKS5: неверная версия протокола: ${response[0]}', + ); + } + + final method = response[1]; + if (method == 0xFF) { + throw SocketException( + 'SOCKS5: сервер отклонил все методы аутентификации', + ); + } + + // 2. Аутентификация (RFC 1929) + if (method == 0x02) { + if (!useAuth) { + throw SocketException('SOCKS5: прокси требует аутентификацию'); + } + final usernameBytes = utf8.encode(settings.username!); + final passwordBytes = utf8.encode(settings.password!); + final authPacket = BytesBuilder() + ..addByte(0x01) + ..addByte(usernameBytes.length) + ..add(usernameBytes) + ..addByte(passwordBytes.length) + ..add(passwordBytes); + await io.write(authPacket.toBytes()); + + final authResponse = await io.readExact(2); + if (authResponse[1] != 0x00) { + throw SocketException('SOCKS5: аутентификация не пройдена'); + } + logger.i('SOCKS5: аутентификация пройдена'); + } + + // 3. Connect request + final hostBytes = utf8.encode(targetHost); + final connectPacket = BytesBuilder() + ..addByte(0x05) // VER + ..addByte(0x01) // CMD: CONNECT + ..addByte(0x00) // RSV + ..addByte(0x03) // ATYP: domain + ..addByte(hostBytes.length) + ..add(hostBytes) + ..addByte((targetPort >> 8) & 0xFF) + ..addByte(targetPort & 0xFF); + await io.write(connectPacket.toBytes()); + + // 4. Reply + final reply = await io.readExact(4); + if (reply[0] != 0x05) { + throw SocketException('SOCKS5: неверная версия в ответе'); + } + if (reply[1] != 0x00) { + throw SocketException( + 'SOCKS5: ошибка подключения, код: ${reply[1]}', + ); + } + + // Пропускаем bind address + switch (reply[3]) { + case 0x01: + await io.readExact(4 + 2); + break; + case 0x03: + final lenBuf = await io.readExact(1); + await io.readExact(lenBuf[0] + 2); + break; + case 0x04: + await io.readExact(16 + 2); + break; + } + + logger.i('SOCKS5: туннель к $targetHost:$targetPort установлен'); + + // Создаём локальную пару и проксируем данные + return _bridgeToFreshSocket(proxySocket, io); + } catch (e) { + io.dispose(); + proxySocket.close(); + rethrow; + } + } + + // ── HTTP CONNECT ──────────────────────────────────────────────────────── + + Future _connectHttpConnect( + String targetHost, + int targetPort, + ) async { + final proxySocket = await RawSocket.connect(settings.host, settings.port); + logger.i( + 'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}', + ); + + final io = _RawSocketIO(proxySocket); + try { + final request = StringBuffer() + ..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n') + ..write('Host: $targetHost:$targetPort\r\n'); + + if (settings.hasCredentials) { + final credentials = base64Encode( + utf8.encode('${settings.username}:${settings.password}'), + ); + request.write('Proxy-Authorization: Basic $credentials\r\n'); + } + request.write('\r\n'); + + await io.write(utf8.encode(request.toString())); + + // Читаем HTTP-ответ до \r\n\r\n + final headerBytes = []; + while (true) { + final byte = await io.readExact(1); + headerBytes.add(byte[0]); + if (headerBytes.length >= 4 && + headerBytes[headerBytes.length - 4] == 0x0D && + headerBytes[headerBytes.length - 3] == 0x0A && + headerBytes[headerBytes.length - 2] == 0x0D && + headerBytes[headerBytes.length - 1] == 0x0A) { + break; + } + if (headerBytes.length > 8192) { + throw SocketException( + 'HTTP CONNECT: заголовок ответа слишком большой', + ); + } + } + + final responseStr = utf8.decode(headerBytes, allowMalformed: true); + final statusLine = responseStr.split('\r\n').first; + final parts = statusLine.split(' '); + final statusCode = parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0; + if (statusCode != 200) { + throw SocketException( + 'HTTP CONNECT: прокси вернул статус $statusCode', + ); + } + + logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен'); + return _bridgeToFreshSocket(proxySocket, io); + } catch (e) { + io.dispose(); + proxySocket.close(); + rethrow; + } + } + + // ── Мост: создаём свежий сокет и проксируем через loopback ───────────── + + /// После handshake proxy-сокет уже прослушан (single-subscription). + /// Создаём пару локальных сокетов через loopback и проксируем данные + /// между прокси-сокетом и одним концом. Второй конец возвращаем — + /// он «свежий» и его можно передать в [RawSecureSocket.secure]. + Future _bridgeToFreshSocket( + RawSocket proxySocket, + _RawSocketIO io, + ) async { + final server = await RawServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + ); + final clientSide = await RawSocket.connect( + InternetAddress.loopbackIPv4, + server.port, + ); + final serverSide = await server.first; + await server.close(); + + // proxy → local (через уже имеющуюся подписку _RawSocketIO) + io.onData = (data) { + serverSide.write(data); + }; + io.onClosed = () { + serverSide.shutdown(SocketDirection.send); + }; + + // local → proxy + serverSide.listen((event) { + if (event == RawSocketEvent.read) { + final data = serverSide.read(); + if (data != null) proxySocket.write(data); + } else if (event == RawSocketEvent.readClosed || + event == RawSocketEvent.closed) { + proxySocket.shutdown(SocketDirection.send); + } + }); + + // Сливаем данные, буферизованные во время handshake + io.flushBuffered(); + + logger.i('Прокси-мост через loopback создан'); + return clientSide; + } +} + +/// Обёртка над единственной подпиской [RawSocket], с буфером для чтения. +/// +/// После handshake переключается в режим моста: +/// данные из proxy-сокета пересылаются через [onData] в loopback-пару. +class _RawSocketIO { + final RawSocket _socket; + late final StreamSubscription _sub; + + final _readBuffer = []; + Completer? _readWaiter; + Completer? _writeWaiter; + bool _closed = false; + Object? _error; + + /// Коллбэк для данных в режиме моста. + void Function(Uint8List data)? onData; + + /// Коллбэк закрытия в режиме моста. + void Function()? onClosed; + + _RawSocketIO(this._socket) { + _sub = _socket.listen( + _onEvent, + onError: (Object err) { + _error = err; + _closed = true; + _readWaiter?.completeError(err); + _readWaiter = null; + _writeWaiter?.completeError(err); + _writeWaiter = null; + }, + ); + } + + void _onEvent(RawSocketEvent event) { + switch (event) { + case RawSocketEvent.read: + final data = _socket.read(); + if (data != null) { + if (onData != null) { + // Режим моста — пересылаем напрямую + onData!(data); + } else { + // Режим handshake — буферизуем + _readBuffer.addAll(data); + _readWaiter?.complete(); + _readWaiter = null; + } + } + break; + case RawSocketEvent.write: + _writeWaiter?.complete(); + _writeWaiter = null; + break; + case RawSocketEvent.readClosed: + case RawSocketEvent.closed: + _closed = true; + onClosed?.call(); + _readWaiter?.completeError( + SocketException('Прокси закрыл соединение'), + ); + _readWaiter = null; + _writeWaiter?.completeError( + SocketException('Прокси закрыл соединение'), + ); + _writeWaiter = null; + break; + } + } + + /// Читает ровно [count] байт. + Future readExact(int count) async { + while (_readBuffer.length < count) { + if (_error != null) throw _error!; + if (_closed) { + throw SocketException( + 'Соединение закрыто ' + '(ожидали $count байт, получили ${_readBuffer.length})', + ); + } + _readWaiter = Completer(); + await _readWaiter!.future.timeout( + const Duration(seconds: 15), + onTimeout: () => + throw SocketException('Тайм-аут при чтении от прокси'), + ); + } + final result = Uint8List.fromList(_readBuffer.sublist(0, count)); + _readBuffer.removeRange(0, count); + return result; + } + + /// Записывает все байты. + Future write(List data) async { + var offset = 0; + while (offset < data.length) { + if (_error != null) throw _error!; + if (_closed) throw SocketException('Соединение закрыто при записи'); + final written = _socket.write(data, offset); + if (written > 0) { + offset += written; + } else { + _writeWaiter = Completer(); + await _writeWaiter!.future.timeout( + const Duration(seconds: 15), + onTimeout: () => + throw SocketException('Тайм-аут при записи в прокси'), + ); + } + } + } + + /// Пересылает данные, оставшиеся в буфере после handshake, в мост. + void flushBuffered() { + if (_readBuffer.isNotEmpty && onData != null) { + onData!(Uint8List.fromList(_readBuffer)); + _readBuffer.clear(); + } + } + + void dispose() { + _sub.cancel(); + } +} diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index e182159..d5b7784 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -10,6 +10,7 @@ import 'package:komet/l10n/terms_of_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; +import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; import 'spoff_redacted_screen.dart'; import '../../widgets/custom_notification.dart'; @@ -470,6 +471,23 @@ class _LoginScreenState extends State { ); } + void _showProxySettingsSheet(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) { + return SafeArea( + child: const ProxySettingsSheet(), + ); + }, + ); + } + void _showSecurityOptions(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; @@ -521,6 +539,7 @@ class _LoginScreenState extends State { ), onTap: () { Navigator.pop(sheetContext); + _showProxySettingsSheet(context); }, ), ListTile( diff --git a/lib/frontend/screens/auth/proxy_settings_sheet.dart b/lib/frontend/screens/auth/proxy_settings_sheet.dart new file mode 100644 index 0000000..8553ab1 --- /dev/null +++ b/lib/frontend/screens/auth/proxy_settings_sheet.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/backend/api.dart'; +import 'package:komet/core/config/proxy_config.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; + +class ProxySettingsSheet extends StatefulWidget { + const ProxySettingsSheet({super.key}); + + @override + State createState() => _ProxySettingsSheetState(); +} + +class _ProxySettingsSheetState extends State { + final _hostController = TextEditingController(); + final _portController = TextEditingController(text: '1080'); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + ProxyType _selectedType = ProxyType.none; + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final settings = await ProxyConfig.load(); + if (!mounted) return; + setState(() { + _selectedType = settings.type; + _hostController.text = settings.host; + _portController.text = '${settings.port}'; + _usernameController.text = settings.username ?? ''; + _passwordController.text = settings.password ?? ''; + }); + } + + Future _apply(AppLocalizations l10n) async { + if (_selectedType == ProxyType.none) { + return _disable(l10n); + } + + final host = _hostController.text.trim(); + final port = int.tryParse(_portController.text.trim()); + if (host.isEmpty || port == null || port < 1 || port > 65535) { + showCustomNotification(context, l10n.proxyInvalidHostOrPort); + return; + } + + setState(() => _busy = true); + try { + final username = _usernameController.text.trim(); + final password = _passwordController.text.trim(); + await ProxyConfig.save(ProxySettings( + type: _selectedType, + host: host, + port: port, + username: username.isNotEmpty ? username : null, + password: password.isNotEmpty ? password : null, + )); + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.proxySettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _disable(AppLocalizations l10n) async { + setState(() => _busy = true); + try { + await ProxyConfig.clear(); + setState(() => _selectedType = ProxyType.none); + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.proxySettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + final isActive = _selectedType != ProxyType.none; + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + l10n.proxySettingsTitle, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + + // Proxy type selector + _buildTypeSelector(cs, l10n), + const SizedBox(height: 16), + + // Fields shown only when proxy is enabled + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: isActive + ? Column( + children: [ + _buildTextField( + controller: _hostController, + label: l10n.proxyHostLabel, + hintText: '127.0.0.1', + cs: cs, + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _portController, + label: l10n.proxyPortLabel, + hintText: _selectedType == ProxyType.socks5 + ? '1080' + : '8080', + cs: cs, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + const SizedBox(height: 16), + _buildTextField( + controller: _usernameController, + label: l10n.proxyUsernameLabel, + cs: cs, + keyboardType: TextInputType.text, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _passwordController, + label: l10n.proxyPasswordLabel, + cs: cs, + keyboardType: TextInputType.visiblePassword, + obscureText: true, + ), + const SizedBox(height: 8), + ], + ) + : const SizedBox.shrink(), + ), + + const SizedBox(height: 16), + FilledButton( + onPressed: _busy ? null : () => _apply(l10n), + child: Text( + isActive ? l10n.proxyApply : l10n.proxyDisable, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTypeSelector(ColorScheme cs, AppLocalizations l10n) { + final labels = { + ProxyType.none: l10n.proxyTypeNone, + ProxyType.socks5: l10n.proxyTypeSocks5, + ProxyType.httpConnect: l10n.proxyTypeHttp, + }; + + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(4), + child: Row( + children: ProxyType.values.map((type) { + final selected = _selectedType == type; + return Expanded( + child: GestureDetector( + onTap: _busy ? null : () => setState(() => _selectedType = type), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: selected ? cs.primary : Colors.transparent, + borderRadius: BorderRadius.circular(9), + ), + alignment: Alignment.center, + child: Text( + labels[type]!, + style: GoogleFonts.inter( + color: selected ? cs.onPrimary : cs.onSurfaceVariant, + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required ColorScheme cs, + String? hintText, + TextInputType? keyboardType, + List? inputFormatters, + bool obscureText = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + enabled: !_busy, + obscureText: obscureText, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: GoogleFonts.inter( + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 15, + ), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 4a37280..ddaba8e 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'security_screen.dart'; @@ -118,6 +119,28 @@ class _SettingsTabState extends State { icon: Symbols.notifications_active, label: 'Уведомления и звук', ), + _SettingsItem( + icon: Symbols.vpn_lock, + label: 'Прокси', + onTap: () { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + builder: (_) { + return SafeArea( + child: const ProxySettingsSheet(), + ); + }, + ); + }, + ), _SettingsItem( icon: Symbols.shield_lock, label: 'Подделка данных', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3d832a0..ae9b610 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -41,5 +41,18 @@ } }, "codeResendSms": "Resend code via SMS", - "codeError2faMissing": "Error: missing data for 2FA" + "codeError2faMissing": "Error: missing data for 2FA", + + "proxySettingsTitle": "Proxy", + "proxyTypeNone": "Disabled", + "proxyTypeSocks5": "SOCKS5", + "proxyTypeHttp": "HTTP(S)", + "proxyHostLabel": "Proxy host", + "proxyPortLabel": "Proxy port", + "proxyUsernameLabel": "Username (optional)", + "proxyPasswordLabel": "Password (optional)", + "proxyApply": "Apply and reconnect", + "proxyDisable": "Disable proxy", + "proxySettingsSaved": "Proxy settings applied", + "proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 05cc593..c029ed3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -307,6 +307,78 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Error: missing data for 2FA'** String get codeError2faMissing; + + /// No description provided for @proxySettingsTitle. + /// + /// In en, this message translates to: + /// **'Proxy'** + String get proxySettingsTitle; + + /// No description provided for @proxyTypeNone. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get proxyTypeNone; + + /// No description provided for @proxyTypeSocks5. + /// + /// In en, this message translates to: + /// **'SOCKS5'** + String get proxyTypeSocks5; + + /// No description provided for @proxyTypeHttp. + /// + /// In en, this message translates to: + /// **'HTTP(S)'** + String get proxyTypeHttp; + + /// No description provided for @proxyHostLabel. + /// + /// In en, this message translates to: + /// **'Proxy host'** + String get proxyHostLabel; + + /// No description provided for @proxyPortLabel. + /// + /// In en, this message translates to: + /// **'Proxy port'** + String get proxyPortLabel; + + /// No description provided for @proxyUsernameLabel. + /// + /// In en, this message translates to: + /// **'Username (optional)'** + String get proxyUsernameLabel; + + /// No description provided for @proxyPasswordLabel. + /// + /// In en, this message translates to: + /// **'Password (optional)'** + String get proxyPasswordLabel; + + /// No description provided for @proxyApply. + /// + /// In en, this message translates to: + /// **'Apply and reconnect'** + String get proxyApply; + + /// No description provided for @proxyDisable. + /// + /// In en, this message translates to: + /// **'Disable proxy'** + String get proxyDisable; + + /// No description provided for @proxySettingsSaved. + /// + /// In en, this message translates to: + /// **'Proxy settings applied'** + String get proxySettingsSaved; + + /// No description provided for @proxyInvalidHostOrPort. + /// + /// In en, this message translates to: + /// **'Enter a valid proxy host and port (1–65535)'** + String get proxyInvalidHostOrPort; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index df7fda7..f7bad3e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -116,4 +116,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get codeError2faMissing => 'Error: missing data for 2FA'; + + @override + String get proxySettingsTitle => 'Proxy'; + + @override + String get proxyTypeNone => 'Disabled'; + + @override + String get proxyTypeSocks5 => 'SOCKS5'; + + @override + String get proxyTypeHttp => 'HTTP(S)'; + + @override + String get proxyHostLabel => 'Proxy host'; + + @override + String get proxyPortLabel => 'Proxy port'; + + @override + String get proxyUsernameLabel => 'Username (optional)'; + + @override + String get proxyPasswordLabel => 'Password (optional)'; + + @override + String get proxyApply => 'Apply and reconnect'; + + @override + String get proxyDisable => 'Disable proxy'; + + @override + String get proxySettingsSaved => 'Proxy settings applied'; + + @override + String get proxyInvalidHostOrPort => + 'Enter a valid proxy host and port (1–65535)'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 6a92039..0524bca 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -118,4 +118,41 @@ class AppLocalizationsRu extends AppLocalizations { @override String get codeError2faMissing => 'Ошибка: отсутствуют данные для 2FA'; + + @override + String get proxySettingsTitle => 'Прокси'; + + @override + String get proxyTypeNone => 'Выключен'; + + @override + String get proxyTypeSocks5 => 'SOCKS5'; + + @override + String get proxyTypeHttp => 'HTTP(S)'; + + @override + String get proxyHostLabel => 'Хост прокси'; + + @override + String get proxyPortLabel => 'Порт прокси'; + + @override + String get proxyUsernameLabel => 'Логин (необязательно)'; + + @override + String get proxyPasswordLabel => 'Пароль (необязательно)'; + + @override + String get proxyApply => 'Применить и переподключиться'; + + @override + String get proxyDisable => 'Отключить прокси'; + + @override + String get proxySettingsSaved => 'Настройки прокси применены'; + + @override + String get proxyInvalidHostOrPort => + 'Укажите корректный хост и порт прокси (1–65535)'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1d92905..babcc9e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -41,5 +41,18 @@ } }, "codeResendSms": "Отправить код по SMS", - "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA" + "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA", + + "proxySettingsTitle": "Прокси", + "proxyTypeNone": "Выключен", + "proxyTypeSocks5": "SOCKS5", + "proxyTypeHttp": "HTTP(S)", + "proxyHostLabel": "Хост прокси", + "proxyPortLabel": "Порт прокси", + "proxyUsernameLabel": "Логин (необязательно)", + "proxyPasswordLabel": "Пароль (необязательно)", + "proxyApply": "Применить и переподключиться", + "proxyDisable": "Отключить прокси", + "proxySettingsSaved": "Настройки прокси применены", + "proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)" } From 0adabfb5b27018cf1317aa6405f6c6d54fc2cf94 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 9 Apr 2026 21:55:53 +0300 Subject: [PATCH 068/323] fix(auth): await session online state when applying custom server --- lib/backend/api.dart | 8 +++++--- .../screens/auth/server_settings_sheet.dart | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 7751155..87fd3d6 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -74,9 +74,11 @@ class Api { await _connection.connect(endpoint.host, endpoint.port); } catch (e) { logger.e('Не удалось подключиться: $e'); - _cleanup(); - _setSessionState(SessionState.disconnected); - _scheduleReconnect(); + if (_sessionState != SessionState.disconnected) { + _cleanup(); + _setSessionState(SessionState.disconnected); + _scheduleReconnect(); + } return; } diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart index 9c85651..534338a 100644 --- a/lib/frontend/screens/auth/server_settings_sheet.dart +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -53,9 +53,14 @@ class _ServerSettingsSheetState extends State { await prefs.setString(ServerConfig.prefHostKey, host); await prefs.setInt(ServerConfig.prefPortKey, port); await api.disconnect(); - await api.connect(); + api.connect(); + final online = await api.stateStream + .firstWhere((s) => + s == SessionState.online || s == SessionState.disconnected) + .timeout(const Duration(seconds: 15), + onTimeout: () => SessionState.disconnected); if (!mounted) return; - if (api.state == SessionState.online) { + if (online == SessionState.online) { showCustomNotification(context, l10n.serverSettingsSaved); } else { showCustomNotification(context, l10n.serverReconnectFailed); @@ -74,9 +79,14 @@ class _ServerSettingsSheetState extends State { _hostController.text = ServerConfig.defaultHost; _portController.text = '${ServerConfig.defaultPort}'; await api.disconnect(); - await api.connect(); + api.connect(); + final online = await api.stateStream + .firstWhere((s) => + s == SessionState.online || s == SessionState.disconnected) + .timeout(const Duration(seconds: 15), + onTimeout: () => SessionState.disconnected); if (!mounted) return; - if (api.state == SessionState.online) { + if (online == SessionState.online) { showCustomNotification(context, l10n.serverSettingsSaved); } else { showCustomNotification(context, l10n.serverReconnectFailed); From fd7a5fc48721d196ce609c35e0a484d73e13d872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sat, 11 Apr 2026 00:00:37 +0700 Subject: [PATCH 069/323] =?UTF-8?q?=D0=B1=D0=BB=D1=8F=20=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D0=B0=D0=B9=D0=B4=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 88 ++++++++++++++++++--- lib/frontend/screens/chats/chat_screen.dart | 58 ++++++++++++++ lib/frontend/widgets/message_bubble.dart | 83 ++++++++++++++++++- lib/models/attachment.dart | 67 ++++++++++++++++ pubspec.lock | 16 ++-- 5 files changed, 288 insertions(+), 24 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 61a2dbb..e9259e9 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1,10 +1,20 @@ import 'dart:convert'; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; import '../../models/attachment.dart'; +class ContactCache { + static final Map _cache = {}; + + static void put(int id, String name) { + _cache[id] = name; + } + + static String? get(int id) => _cache[id]; +} + class CachedMessage { final String id; final int accountId; @@ -39,11 +49,20 @@ class CachedMessage { List? attachments; if (payload != null) { - final attaches = payload['attaches'] as List?; - if (attaches != null) { - attachments = attaches - .map((a) => MessageAttachment.fromMap(a as Map)) - .toList(); + final linkType = payload['link']?['type'] as String?; + if (linkType == 'FORWARD') { + attachments = [ForwardedMessageAttachment.fromMap(payload)]; + } else { + final attaches = payload['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .map( + (a) => MessageAttachment.fromMap( + Map.from(a as Map), + ), + ) + .toList(); + } } } @@ -158,13 +177,24 @@ class MessagesModule { final id = m['id']?.toString(); if (id == null) return null; - final attaches = m['attaches'] as List?; + final linkRaw = m['link']; + String? linkType; + if (linkRaw is Map) { + linkType = linkRaw['type'] as String?; + } + List? attachments; - if (attaches != null) { - attachments = attaches - .whereType() - .map((a) => MessageAttachment.fromMap(a.cast())) - .toList(); + if (linkType == 'FORWARD') { + final fwdMap = Map.from(m.cast()); + attachments = [ForwardedMessageAttachment.fromMap(fwdMap)]; + } else { + final attaches = m['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } } return CachedMessage( @@ -175,7 +205,7 @@ class MessagesModule { text: m['text'] as String?, time: (m['time'] as int?) ?? 0, status: m['status'] as String?, - payload: m.cast(), + payload: Map.from(m.cast()), attachments: attachments, ); } @@ -313,4 +343,36 @@ class MessagesModule { return null; } } + + Future searchContactById(int contactId) async { + final cached = ContactCache.get(contactId); + if (cached != null) return cached; + + try { + final response = await _api.sendRequest(Opcode.contactInfo, { + 'id': contactId, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final names = data['names'] as List?; + if (names != null && names.isNotEmpty) { + final name = names.first; + if (name is Map) { + final firstName = name['firstName'] as String? ?? ''; + final lastName = name['lastName'] as String?; + final fullName = lastName != null + ? '$firstName $lastName' + : firstName; + ContactCache.put(contactId, fullName); + return fullName; + } + } + } catch (e) { + debugPrint('searchContactById error: $e'); + } + return null; + } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 2944231..1968d3b 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -5,6 +5,7 @@ import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; +import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { @@ -81,6 +82,7 @@ class _ChatScreenState extends State _isLoading = false; }); } + _loadForwardedSenderNames(); } catch (e) { debugPrint('Error fetching history: $e'); if (mounted) { @@ -164,6 +166,62 @@ class _ChatScreenState extends State } } + Future _loadForwardedSenderNames() async { + final forwardIds = {}; + for (final msg in _messages) { + if (msg.attachments != null) { + for (final a in msg.attachments!) { + if (a is ForwardedMessageAttachment) { + if (a.originalSenderName == null) { + forwardIds.add(a.originalSenderId); + } + } + } + } + } + if (forwardIds.isEmpty) return; + + for (final id in forwardIds) { + final name = await messagesModule.searchContactById(id); + if (name != null && mounted) { + setState(() { + for (var i = 0; i < _messages.length; i++) { + final msg = _messages[i]; + if (msg.attachments != null) { + final newAttaches = msg.attachments!.map((a) { + if (a is ForwardedMessageAttachment && + a.originalSenderId == id && + a.originalSenderName == null) { + return ForwardedMessageAttachment( + originalSenderId: id, + originalSenderName: name, + originalMessageId: a.originalMessageId, + originalTime: a.originalTime, + originalText: a.originalText, + originalChatId: a.originalChatId, + originalAttachments: a.originalAttachments, + ); + } + return a; + }).toList(); + _messages[i] = CachedMessage( + id: msg.id, + accountId: msg.accountId, + chatId: msg.chatId, + senderId: msg.senderId, + text: msg.text, + time: msg.time, + status: msg.status, + payload: msg.payload, + attachments: newAttaches, + ); + } + } + }); + } + } + } + void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index c3a5b62..100a1fd 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -78,6 +78,7 @@ class MessageBubble extends StatelessWidget { MessageType get contentType { if (message.attachments != null && message.attachments!.isNotEmpty) { final first = message.attachments!.first; + if (first is ForwardedMessageAttachment) return MessageType.text; if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; return MessageType.attachment; @@ -317,14 +318,26 @@ class MessageBubble extends StatelessWidget { ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final forwarded = _getForwardedAttachment(); + return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( - child: Text( - message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (forwarded != null) ...[ + _buildForwardedHeader(context, forwarded), + const SizedBox(height: 4), + ], + Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), + ], ), ), const SizedBox(width: 8), @@ -343,12 +356,76 @@ class MessageBubble extends StatelessWidget { ); } + ForwardedMessageAttachment? _getForwardedAttachment() { + if (message.attachments == null || message.attachments!.isEmpty) + return null; + for (final a in message.attachments!) { + if (a is ForwardedMessageAttachment) return a; + } + return null; + } + + Widget _buildForwardedHeader( + BuildContext context, + ForwardedMessageAttachment forwarded, + ) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + + final senderName = forwarded.originalSenderName; + final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final origText = forwarded.originalText; + final hasOrigText = origText != null && origText.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (hasOrigText) ...[ + const SizedBox(height: 2), + Text( + origText, + style: TextStyle(color: textColor, fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ); + } + Widget _buildAttachmentContent(BuildContext context) { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { return _buildTextContent(context); } + final first = attachments.first; + if (first is ForwardedMessageAttachment) { + return _buildTextContent(context); + } + final photos = attachments.whereType().toList(); if (photos.isEmpty) { return _buildGenericAttachment(context, attachments.first); diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index dbaf79e..3da1153 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -433,6 +433,73 @@ class ControlAttachment extends MessageAttachment { }; } +class ForwardedMessageAttachment extends MessageAttachment { + final int originalSenderId; + final String? originalSenderName; + final String? originalMessageId; + final int? originalTime; + final String? originalText; + final int? originalChatId; + final List? originalAttachments; + + const ForwardedMessageAttachment({ + required this.originalSenderId, + this.originalSenderName, + this.originalMessageId, + this.originalTime, + this.originalText, + this.originalChatId, + this.originalAttachments, + }) : super(type: AttachmentType.photo); + + factory ForwardedMessageAttachment.fromMap(Map map) { + final linkRaw = map['link']; + Map? link; + if (linkRaw is Map) { + link = Map.from(linkRaw); + } + + Map? message; + if (link != null) { + final msgRaw = link['message']; + if (msgRaw is Map) { + message = Map.from(msgRaw); + } + } + + List? originalAttaches; + if (message != null) { + final attaches = message['attaches'] as List?; + if (attaches != null) { + originalAttaches = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + } + + return ForwardedMessageAttachment( + originalSenderId: (message?['sender'] as int?) ?? 0, + originalMessageId: message?['id']?.toString(), + originalTime: message?['time'] as int?, + originalText: message?['text'] as String?, + originalChatId: link?['chatId'] as int?, + originalAttachments: originalAttaches, + ); + } + + @override + Map toMap() => { + '_type': 'FORWARD', + 'originalSenderId': originalSenderId, + 'originalSenderName': originalSenderName, + 'originalMessageId': originalMessageId, + 'originalTime': originalTime, + 'originalText': originalText, + 'originalChatId': originalChatId, + }; +} + class UnknownAttachment extends MessageAttachment { final Map rawData; diff --git a/pubspec.lock b/pubspec.lock index b09c9ff..1f7d572 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: From 8da19c569da41836b57688ae3e15557053b21faa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sat, 11 Apr 2026 00:09:38 +0700 Subject: [PATCH 070/323] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=81=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=84=D0=BE=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/message_bubble.dart | 171 +++++++++++++++++++---- 1 file changed, 143 insertions(+), 28 deletions(-) diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 100a1fd..352435d 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -78,7 +78,13 @@ class MessageBubble extends StatelessWidget { MessageType get contentType { if (message.attachments != null && message.attachments!.isNotEmpty) { final first = message.attachments!.first; - if (first is ForwardedMessageAttachment) return MessageType.text; + if (first is ForwardedMessageAttachment) { + final fwd = first; + final hasPhoto = + fwd.originalAttachments != null && + fwd.originalAttachments!.any((a) => a is PhotoAttachment); + return hasPhoto ? MessageType.attachment : MessageType.text; + } if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; return MessageType.attachment; @@ -319,26 +325,19 @@ class MessageBubble extends StatelessWidget { : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); final forwarded = _getForwardedAttachment(); + final isForwarded = forwarded != null; return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (forwarded != null) ...[ - _buildForwardedHeader(context, forwarded), - const SizedBox(height: 4), - ], - Text( - message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), - ), - ], - ), + child: isForwarded + ? _buildForwardedInlineText(context, forwarded, textColor) + : Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), ), const SizedBox(width: 8), Padding( @@ -356,6 +355,58 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildForwardedInlineText( + BuildContext context, + ForwardedMessageAttachment forwarded, + Color textColor, + ) { + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.onSurfaceVariant; + + final senderName = forwarded.originalSenderName; + final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final origText = forwarded.originalText; + final hasOrigText = origText != null && origText.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (hasOrigText) ...[ + const SizedBox(height: 2), + Text( + origText, + style: TextStyle(color: textColor, fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ] else ...[ + const SizedBox(height: 2), + Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), + ], + ], + ); + } + ForwardedMessageAttachment? _getForwardedAttachment() { if (message.attachments == null || message.attachments!.isEmpty) return null; @@ -387,20 +438,23 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.forward, size: 14, color: headerColor), - const SizedBox(width: 4), - Text( - displaySender, - style: TextStyle( - color: headerColor, - fontSize: 12, - fontWeight: FontWeight.w500, + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), - ), - ], + ], + ), ), if (hasOrigText) ...[ const SizedBox(height: 2), @@ -423,6 +477,13 @@ class MessageBubble extends StatelessWidget { final first = attachments.first; if (first is ForwardedMessageAttachment) { + final fwd = first; + final photos = fwd.originalAttachments + ?.whereType() + .toList(); + if (photos != null && photos.isNotEmpty) { + return _buildForwardedPhotoContent(context, fwd, photos); + } return _buildTextContent(context); } @@ -514,6 +575,60 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildForwardedPhotoContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List photos, + ) { + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.onSurfaceVariant; + final senderName = forwarded.originalSenderName; + final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final hasCaption = message.text != null && message.text!.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + if (hasCaption) ...[ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + message.text ?? '', + style: TextStyle( + color: isMe ? Colors.white : const Color(0xFF1C1C1E), + fontSize: 16, + height: 1.3, + ), + ), + ), + const SizedBox(height: 6), + ], + _buildPhotoContent(context, photos), + ], + ); + } + Widget _buildSinglePhoto(BuildContext ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; final width = photo.width?.toDouble() ?? 200; From 78635dd3278a95621acace8b4f66249adcee0393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sat, 11 Apr 2026 00:17:28 +0700 Subject: [PATCH 071/323] =?UTF-8?q?=D0=91=D0=BB=D1=8F=20=D0=BB=D0=B5=D1=88?= =?UTF-8?q?=D0=B0=20=D1=8F=20=D1=82=D0=B2=D0=BE=D0=B8=20=D0=BF=D1=8F=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=86=D0=B5=D0=BB=D0=BE=D0=B2=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e9259e9..13f8af2 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -350,24 +350,30 @@ class MessagesModule { try { final response = await _api.sendRequest(Opcode.contactInfo, { - 'id': contactId, + 'contactIds': [contactId], }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; - final names = data['names'] as List?; - if (names != null && names.isNotEmpty) { - final name = names.first; - if (name is Map) { - final firstName = name['firstName'] as String? ?? ''; - final lastName = name['lastName'] as String?; - final fullName = lastName != null - ? '$firstName $lastName' - : firstName; - ContactCache.put(contactId, fullName); - return fullName; + final contacts = data['contacts'] as List?; + if (contacts != null && contacts.isNotEmpty) { + final contact = contacts.first; + if (contact is Map) { + final names = contact['names'] as List?; + if (names != null && names.isNotEmpty) { + final name = names.first; + if (name is Map) { + final firstName = name['firstName'] as String? ?? ''; + final lastName = name['lastName'] as String?; + final fullName = lastName != null + ? '$firstName $lastName' + : firstName; + ContactCache.put(contactId, fullName); + return fullName; + } + } } } } catch (e) { From ef1daff89606ec2f0cdbf287d6a73bfe5c064402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sat, 11 Apr 2026 00:22:32 +0700 Subject: [PATCH 072/323] =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=BE4=D0=BA=D0=B8=20=D0=B2=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 18 +++++++-- lib/frontend/screens/chats/chat_screen.dart | 3 ++ lib/frontend/widgets/message_bubble.dart | 44 ++++++++++++++++++++- lib/l10n/app_localizations.dart | 37 ++++++++--------- lib/l10n/app_localizations_en.dart | 9 ++--- lib/l10n/app_localizations_ru.dart | 15 +++---- lib/models/attachment.dart | 3 ++ 7 files changed, 87 insertions(+), 42 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 13f8af2..00dd1c0 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -6,13 +6,21 @@ import '../../core/storage/app_database.dart'; import '../../models/attachment.dart'; class ContactCache { - static final Map _cache = {}; + static final Map _nameCache = {}; + static final Map _avatarCache = {}; static void put(int id, String name) { - _cache[id] = name; + _nameCache[id] = name; } - static String? get(int id) => _cache[id]; + static void putAvatar(int id, String? baseUrl) { + if (baseUrl != null) { + _avatarCache[id] = baseUrl; + } + } + + static String? get(int id) => _nameCache[id]; + static String? getAvatar(int id) => _avatarCache[id]; } class CachedMessage { @@ -371,6 +379,10 @@ class MessagesModule { ? '$firstName $lastName' : firstName; ContactCache.put(contactId, fullName); + + final baseUrl = contact['baseUrl'] as String?; + ContactCache.putAvatar(contactId, baseUrl); + return fullName; } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 1968d3b..a05c853 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -6,6 +6,7 @@ import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; import '../../../models/attachment.dart'; +import '../../../backend/modules/messages.dart' show ContactCache; import '../../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { @@ -183,6 +184,7 @@ class _ChatScreenState extends State for (final id in forwardIds) { final name = await messagesModule.searchContactById(id); + final avatar = ContactCache.getAvatar(id); if (name != null && mounted) { setState(() { for (var i = 0; i < _messages.length; i++) { @@ -195,6 +197,7 @@ class _ChatScreenState extends State return ForwardedMessageAttachment( originalSenderId: id, originalSenderName: name, + originalSenderAvatar: avatar, originalMessageId: a.originalMessageId, originalTime: a.originalTime, originalText: a.originalText, diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 352435d..647da92 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -360,12 +360,14 @@ class MessageBubble extends StatelessWidget { ForwardedMessageAttachment forwarded, Color textColor, ) { + final cs = Theme.of(context).colorScheme; final headerColor = isMe ? Colors.white.withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurfaceVariant; + : cs.onSurfaceVariant; final senderName = forwarded.originalSenderName; final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; final origText = forwarded.originalText; final hasOrigText = origText != null && origText.isNotEmpty; @@ -378,6 +380,24 @@ class MessageBubble extends StatelessWidget { children: [ Icon(Symbols.forward, size: 14, color: headerColor), const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), Text( displaySender, style: TextStyle( @@ -580,11 +600,13 @@ class MessageBubble extends StatelessWidget { ForwardedMessageAttachment forwarded, List photos, ) { + final cs = Theme.of(context).colorScheme; final headerColor = isMe ? Colors.white.withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurfaceVariant; + : cs.onSurfaceVariant; final senderName = forwarded.originalSenderName; final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; final hasCaption = message.text != null && message.text!.isNotEmpty; return Column( @@ -598,6 +620,24 @@ class MessageBubble extends StatelessWidget { children: [ Icon(Symbols.forward, size: 14, color: headerColor), const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), Text( displaySender, style: TextStyle( diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c029ed3..6741f70 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -62,8 +62,7 @@ import 'app_localizations_ru.dart'; /// be consistent with the languages listed in the AppLocalizations.supportedLocales /// property. abstract class AppLocalizations { - AppLocalizations(String locale) - : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -71,8 +70,7 @@ abstract class AppLocalizations { return Localizations.of(context, AppLocalizations); } - static const LocalizationsDelegate delegate = - _AppLocalizationsDelegate(); + static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); /// A list of this localizations delegate along with the default localizations /// delegates. @@ -84,18 +82,17 @@ abstract class AppLocalizations { /// Additional delegates can be added by appending to this list in /// MaterialApp. This list does not have to be used at all if a custom list /// of delegates is preferred or required. - static const List> localizationsDelegates = - >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + static const List> localizationsDelegates = >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ Locale('en'), - Locale('ru'), + Locale('ru') ]; /// No description provided for @loginTitle. @@ -381,8 +378,7 @@ abstract class AppLocalizations { String get proxyInvalidHostOrPort; } -class _AppLocalizationsDelegate - extends LocalizationsDelegate { +class _AppLocalizationsDelegate extends LocalizationsDelegate { const _AppLocalizationsDelegate(); @override @@ -391,26 +387,25 @@ class _AppLocalizationsDelegate } @override - bool isSupported(Locale locale) => - ['en', 'ru'].contains(locale.languageCode); + bool isSupported(Locale locale) => ['en', 'ru'].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; } AppLocalizations lookupAppLocalizations(Locale locale) { + + // Lookup logic when only language code is specified. switch (locale.languageCode) { - case 'en': - return AppLocalizationsEn(); - case 'ru': - return AppLocalizationsRu(); + case 'en': return AppLocalizationsEn(); + case 'ru': return AppLocalizationsRu(); } throw FlutterError( 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' 'an issue with the localizations generation tool. Please file an issue ' 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.', + 'that was used.' ); } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f7bad3e..68d2d21 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -12,8 +12,7 @@ class AppLocalizationsEn extends AppLocalizations { String get loginTitle => 'Sign in to Komet'; @override - String get loginSubtitle => - 'Check your country code and enter your\nphone number.'; + String get loginSubtitle => 'Check your country code and enter your\nphone number.'; @override String get loginCountry => 'Country'; @@ -103,8 +102,7 @@ class AppLocalizationsEn extends AppLocalizations { String get selectCountrySearchHint => 'Search countries…'; @override - String get codeConfirmationSmsSent => - 'We sent an SMS with a verification code to your phone number.'; + String get codeConfirmationSmsSent => 'We sent an SMS with a verification code to your phone number.'; @override String codeResendInSeconds(int seconds) { @@ -151,6 +149,5 @@ class AppLocalizationsEn extends AppLocalizations { String get proxySettingsSaved => 'Proxy settings applied'; @override - String get proxyInvalidHostOrPort => - 'Enter a valid proxy host and port (1–65535)'; + String get proxyInvalidHostOrPort => 'Enter a valid proxy host and port (1–65535)'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 0524bca..7f58d45 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -12,8 +12,7 @@ class AppLocalizationsRu extends AppLocalizations { String get loginTitle => 'Войдите в Komet'; @override - String get loginSubtitle => - 'Проверьте код страны и введите свой\nномер телефона.'; + String get loginSubtitle => 'Проверьте код страны и введите свой\nномер телефона.'; @override String get loginCountry => 'Страна'; @@ -43,8 +42,7 @@ class AppLocalizationsRu extends AppLocalizations { String get loginDone => 'Готово'; @override - String get loginReadTermsNotification => - 'Сначала прочитайте условия использования'; + String get loginReadTermsNotification => 'Сначала прочитайте условия использования'; @override String get loginSpoofRedacted => 'Подделка спуфа'; @@ -71,8 +69,7 @@ class AppLocalizationsRu extends AppLocalizations { String get serverUseDefault => 'Сбросить к умолчанию'; @override - String get serverInvalidHostOrPort => - 'Укажите корректный хост и порт (1–65535)'; + String get serverInvalidHostOrPort => 'Укажите корректный хост и порт (1–65535)'; @override String get serverSettingsSaved => 'Настройки сервера применены'; @@ -105,8 +102,7 @@ class AppLocalizationsRu extends AppLocalizations { String get selectCountrySearchHint => 'Поиск страны…'; @override - String get codeConfirmationSmsSent => - 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.'; + String get codeConfirmationSmsSent => 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.'; @override String codeResendInSeconds(int seconds) { @@ -153,6 +149,5 @@ class AppLocalizationsRu extends AppLocalizations { String get proxySettingsSaved => 'Настройки прокси применены'; @override - String get proxyInvalidHostOrPort => - 'Укажите корректный хост и порт прокси (1–65535)'; + String get proxyInvalidHostOrPort => 'Укажите корректный хост и порт прокси (1–65535)'; } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 3da1153..2c43222 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -436,6 +436,7 @@ class ControlAttachment extends MessageAttachment { class ForwardedMessageAttachment extends MessageAttachment { final int originalSenderId; final String? originalSenderName; + final String? originalSenderAvatar; final String? originalMessageId; final int? originalTime; final String? originalText; @@ -445,6 +446,7 @@ class ForwardedMessageAttachment extends MessageAttachment { const ForwardedMessageAttachment({ required this.originalSenderId, this.originalSenderName, + this.originalSenderAvatar, this.originalMessageId, this.originalTime, this.originalText, @@ -493,6 +495,7 @@ class ForwardedMessageAttachment extends MessageAttachment { '_type': 'FORWARD', 'originalSenderId': originalSenderId, 'originalSenderName': originalSenderName, + 'originalSenderAvatar': originalSenderAvatar, 'originalMessageId': originalMessageId, 'originalTime': originalTime, 'originalText': originalText, From 46d969713c534f7b52508400c962a55e8e098263 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 11 Apr 2026 00:21:31 +0300 Subject: [PATCH 073/323] feat(spoof): port session spoofing screen with device presets --- lib/backend/api.dart | 53 +- lib/core/config/device_presets.dart | 1013 +++++++++++++++++ lib/core/config/spoof_data.dart | 31 - lib/core/storage/spoofing_service.dart | 27 + lib/frontend/screens/auth/login_screen.dart | 4 +- .../screens/auth/spoff_redacted_screen.dart | 217 ---- .../screens/profile/settings_tab.dart | 3 +- .../screens/profile/spoof_screen.dart | 980 ++++++++++++---- lib/l10n/app_en.arb | 48 +- lib/l10n/app_localizations.dart | 255 ++++- lib/l10n/app_localizations_en.dart | 127 ++- lib/l10n/app_localizations_ru.dart | 133 ++- lib/l10n/app_ru.arb | 48 +- 13 files changed, 2398 insertions(+), 541 deletions(-) create mode 100644 lib/core/config/device_presets.dart delete mode 100644 lib/core/config/spoof_data.dart create mode 100644 lib/core/storage/spoofing_service.dart delete mode 100644 lib/frontend/screens/auth/spoff_redacted_screen.dart diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 87fd3d6..0ff3ace 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -5,6 +5,7 @@ import '../core/config/config.dart'; import '../core/config/countries.dart'; import '../core/protocol/opcode_map.dart'; import '../core/protocol/packet.dart'; +import '../core/storage/spoofing_service.dart'; import '../core/transport/connection.dart'; import '../core/transport/dispatcher.dart'; import '../core/transport/receiver.dart'; @@ -112,7 +113,7 @@ class Api { Future sendHandshake() async { final deviceInfo = DeviceInfoPlugin(); - final deviceType = (Platform.isLinux || Platform.isWindows) + String deviceType = (Platform.isLinux || Platform.isWindows) ? 'DESKTOP' : (Platform.isAndroid) ? 'ANDROID' @@ -120,10 +121,16 @@ class Api { String osVersion = ''; String deviceName = 'Unknown'; String architecture = 'arm64'; + String appVersion = SpoofingService.hardcodedAppVersion; + int buildNumber = SpoofingService.hardcodedBuildNumber; + String screen = '1920x1080'; tz.initializeTimeZones(); final timeZoneName = await FlutterTimezone.getLocalTimezone(); - final timezone = timeZoneName.identifier; + String timezone = timeZoneName.identifier; + String locale = 'ru'; + String deviceLocale = Platform.localeName.substring(0, 2); + String deviceId = 'a1b2c3d4e5f6a7b8'; if (Platform.isLinux) { final linuxInfo = await deviceInfo.linuxInfo; @@ -150,24 +157,54 @@ class Api { ); } + final spoofed = await SpoofingService.getSpoofedSessionData(); + if (spoofed != null) { + deviceType = (spoofed['device_type'] as String?) ?? deviceType; + final sDeviceName = spoofed['device_name'] as String?; + if (sDeviceName != null && sDeviceName.isNotEmpty) { + deviceName = sDeviceName; + } + final sOsVersion = spoofed['os_version'] as String?; + if (sOsVersion != null && sOsVersion.isNotEmpty) osVersion = sOsVersion; + final sScreen = spoofed['screen'] as String?; + if (sScreen != null && sScreen.isNotEmpty) screen = sScreen; + final sTimezone = spoofed['timezone'] as String?; + if (sTimezone != null && sTimezone.isNotEmpty) timezone = sTimezone; + final sLocale = spoofed['locale'] as String?; + if (sLocale != null && sLocale.isNotEmpty) { + locale = sLocale; + deviceLocale = sLocale.split(RegExp(r'[-_]')).first; + } + final sDeviceId = spoofed['device_id'] as String?; + if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId; + appVersion = (spoofed['app_version'] as String?) ?? appVersion; + architecture = (spoofed['arch'] as String?) ?? architecture; + final sBuild = spoofed['build_number']; + if (sBuild is int) { + buildNumber = sBuild; + } else if (sBuild is String) { + buildNumber = int.tryParse(sBuild) ?? buildNumber; + } + } + _userAgent = { 'deviceType': deviceType, - 'locale': 'ru', - 'deviceLocale': Platform.localeName.substring(0, 2), + 'locale': locale, + 'deviceLocale': deviceLocale, 'osVersion': osVersion, 'deviceName': deviceName, - 'appVersion': '26.8.1', - 'screen': '1920x1080', + 'appVersion': appVersion, + 'screen': screen, 'timezone': timezone, 'pushDeviceType': 'GCM', 'arch': architecture, - 'buildNumber': 6606, + 'buildNumber': buildNumber, }; final payload = { 'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000', 'clientSessionId': 42, - 'deviceId': 'a1b2c3d4e5f6a7b8', + 'deviceId': deviceId, 'userAgent': _userAgent, }; diff --git a/lib/core/config/device_presets.dart b/lib/core/config/device_presets.dart new file mode 100644 index 0000000..de7506e --- /dev/null +++ b/lib/core/config/device_presets.dart @@ -0,0 +1,1013 @@ +class DevicePreset { + final String deviceType; + final String userAgent; + final String deviceName; + final String osVersion; + final String screen; + final String timezone; + final String locale; + + DevicePreset({ + required this.deviceType, + required this.userAgent, + required this.deviceName, + required this.osVersion, + required this.screen, + required this.timezone, + required this.locale, + }); +} + +final List devicePresets = [ + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24 Ultra', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 8 Pro', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1344x2992', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; 23021RAA2Y) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 13 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; CPH2521) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 12', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3168', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S21 Ultra', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 6', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'America/Chicago', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; RMX3371) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Realme GT Master Edition', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; M2101K6G) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36', + deviceName: 'Poco F3', + osVersion: 'Android 11', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SO-51D) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Sony Xperia 1 V', + osVersion: 'Android 14', + screen: 'xxxhdpi 560dpi 1644x3840', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; XT2201-2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Motorola Edge 30 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-A546E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy A54', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2340', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; 2201116SG) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Redmi Note 11 Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; ZS676KS) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Asus ROG Phone 6', + osVersion: 'Android 13', + screen: 'xxhdpi 420dpi 1080x2448', + timezone: 'Asia/Taipei', + locale: 'zh-TW', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 10; TA-1021) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36', + deviceName: 'Nokia 8', + osVersion: 'Android 10', + screen: 'xxhdpi 380dpi 1440x2560', + timezone: 'Europe/Helsinki', + locale: 'fi-FI', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; PGT-N19) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Huawei P60 Pro', + osVersion: 'Android 13 (EMUI)', + screen: 'xxhdpi 430dpi 1220x2700', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 9; LM-G710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Mobile Safari/537.36', + deviceName: 'LG G7 ThinQ', + osVersion: 'Android 9', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'Asia/Seoul', + locale: 'ko-KR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Nothing Phone (2)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Nothing Phone (2)', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2412', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-F936U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy Z Fold 4', + osVersion: 'Android 13', + screen: 'xhdpi 350dpi 1812x2176', + timezone: 'America/Denver', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; LE2113) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 9', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 7a', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Europe/Amsterdam', + locale: 'nl-NL', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'America/Vancouver', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; M2101K6C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Poco F3 GT', + osVersion: 'Android 13', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; V29) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Vivo V29', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Bangkok', + locale: 'th-TH', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; K30 Ultra) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi K30 Ultra', + osVersion: 'Android 13', + screen: 'xxhdpi 450dpi 1440x3200', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; P80) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Oppo Find N3', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1440x3168', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-G916B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S20 FE', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; CPH2135) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 8 Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 450dpi 1440x3168', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; S24E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24 Edge', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; LE2120) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 9 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3216', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; A14 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 9 Pro', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1344x2992', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; 21091116AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 12T', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; SM-F711B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy Z Flip 3', + osVersion: 'Android 12', + screen: 'xhdpi 370dpi 1080x2640', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; XT2201-3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Motorola Edge 40', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; 23088RA9AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 14 Ultra', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3200', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; CPH2487) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 11', + osVersion: 'Android 13', + screen: 'xxhdpi 450dpi 1440x3216', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; M2004J19C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi Mi 10T Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-A546B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy A55', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; RMX3761) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Realme GT Neo 3', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Hong_Kong', + locale: 'zh-HK', + ), + + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 15 Pro Max', + osVersion: 'iOS 17.5.1', + screen: '1290x2796 3.0x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13', + osVersion: 'iOS 16.7', + screen: '1170x2532 3.0x', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.88 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Pro 11-inch', + osVersion: 'iPadOS 17.5', + screen: '1668x2388 2.0x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/125.0 Mobile/15E148', + deviceName: 'iPhone 14 Pro', + osVersion: 'iOS 17.4.1', + screen: '1179x2556 3.0x', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone SE (2020)', + osVersion: 'iOS 15.8', + screen: '750x1334 2.0x', + timezone: 'Australia/Melbourne', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPR/55.0.2519.144889 Mobile/15E148', + deviceName: 'iPhone 15', + osVersion: 'iOS 17.1', + screen: '1179x2556 3.0x', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air 5th Gen', + osVersion: 'iPadOS 16.5', + screen: '1640x2360 2.0x', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 15 Pro', + osVersion: 'iOS 17.5', + screen: '1179x2556 3.0x', + timezone: 'Asia/Singapore', + locale: 'en-SG', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 14', + osVersion: 'iOS 17.4', + screen: '1170x2532 3.0x', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12 Pro Max', + osVersion: 'iOS 16.6', + screen: '1284x2778 3.0x', + timezone: 'Asia/Bangkok', + locale: 'th-TH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 11 Pro', + osVersion: 'iOS 15.7', + screen: '1125x2436 3.0x', + timezone: 'Europe/Istanbul', + locale: 'tr-TR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Mini 6th Gen', + osVersion: 'iPadOS 17.5', + screen: '1488x2266 2.0x', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.7 Mobile/15E148 Safari/604.1', + deviceName: 'iPad 10th Gen', + osVersion: 'iPadOS 16.7', + screen: '1620x2160 2.0x', + timezone: 'Asia/Hong_Kong', + locale: 'zh-HK', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13 Pro', + osVersion: 'iOS 17.3', + screen: '1170x2532 3.0x', + timezone: 'Europe/Dublin', + locale: 'en-IE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12', + osVersion: 'iOS 16.5', + screen: '1125x2436 3.0x', + timezone: 'Asia/Mumbai', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Pro 12.9-inch', + osVersion: 'iPadOS 15.8', + screen: '2048x2732 2.0x', + timezone: 'Europe/Vienna', + locale: 'de-AT', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone XS Max', + osVersion: 'iOS 14.8', + screen: '1125x2436 3.0x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13 mini', + osVersion: 'iOS 17.2', + screen: '1080x2340 3.0x', + timezone: 'America/Miami', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air 4th Gen', + osVersion: 'iPadOS 14.8', + screen: '1640x2360 2.0x', + timezone: 'Europe/Zurich', + locale: 'de-CH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 11', + osVersion: 'iOS 16.4', + screen: '828x1792 2.0x', + timezone: 'America/Argentina/Buenos_Aires', + locale: 'es-AR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12 mini', + osVersion: 'iOS 17.1', + screen: '1080x2340 3.0x', + timezone: 'Europe/Brussels', + locale: 'nl-BE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air Pro 11-inch', + osVersion: 'iPadOS 17.4', + screen: '2388x1668 2.0x', + timezone: 'Asia/Bangkok', + locale: 'en-TH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone XR', + osVersion: 'iOS 13.7', + screen: '828x1792 2.0x', + timezone: 'Europe/Lisbon', + locale: 'pt-PT', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPad (9th generation)', + osVersion: 'iPadOS 17.3', + screen: '1620x2160 2.0x', + timezone: 'Europe/Prague', + locale: 'cs-CZ', + ), + + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Windows PC', + osVersion: 'Windows 11', + screen: '1920x1080 1.25x', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'MacBook Pro', + osVersion: 'macOS 14.5 Sonoma', + screen: '1728x1117 2.0x', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Linux PC', + osVersion: 'Ubuntu 24.04 LTS', + screen: '2560x1440 1.0x', + timezone: 'UTC', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Windows PC (Firefox)', + osVersion: 'Windows 10', + screen: '1536x864 1.0x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15', + deviceName: 'iMac (Safari)', + osVersion: 'macOS 13.6 Ventura', + screen: '3840x2160 1.5x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows ', + screen: '1920x1080', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '2560x1440', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.2420.97', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1536x864', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '1920x1200', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '1366x768', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1440x900', + timezone: 'Europe/Amsterdam', + locale: 'nl-NL', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 6.3; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1600x900', + timezone: 'Europe/Warsaw', + locale: 'pl-PL', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'America/Chicago', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.109', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1366x768', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 14.5', + screen: '2560x1440', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 13.6', + screen: '1440x900', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_7_10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 11.7', + screen: '1728x1117', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 12.5', + screen: '2048x1152', + timezone: 'Europe/London', + locale: 'en-GB', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'macOS 14.5', + screen: '1920x1080', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'macOS 13.0', + screen: '1680x1050', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 14.5', + screen: '1440x900', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 13.6', + screen: '2560x1600', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 10.14', + screen: '1280x800', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1366x768', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Chrome OS', + screen: '1920x1080', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1600x900', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'UTC', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '2560x1440', + timezone: 'America/Denver', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '1366x768', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0', + deviceName: 'Opera', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'Europe/Oslo', + locale: 'no-NO', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Vivaldi/6.5.3206.63', + deviceName: 'Vivaldi', + osVersion: 'macOS 14.0', + screen: '1440x900', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1280x720', + timezone: 'Asia/Seoul', + locale: 'ko-KR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'Europe/Helsinki', + locale: 'fi-FI', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 10.13', + screen: '1280x800', + timezone: 'America/Vancouver', + locale: 'en-CA', + ), +]; diff --git a/lib/core/config/spoof_data.dart b/lib/core/config/spoof_data.dart deleted file mode 100644 index f60c3ff..0000000 --- a/lib/core/config/spoof_data.dart +++ /dev/null @@ -1,31 +0,0 @@ -class SpoofData { - static const List deviceNames = [ - 'Samsung Galaxy S23', - 'Xiaomi 13 Pro', - 'Google Pixel 7', - 'OnePlus 11', - ]; - - static const List osVersions = ['12', '13', '14']; - - static const List resolutions = [ - '1080x2400', - '1440x3200', - '720x1600', - ]; - - static const List deviceIds = [ - 'a1b2c3d4e5f6', - 'f8e7d6c5b4a3', - '9876543210ab', - '1234567890cd', - ]; - - static const List architectures = ['arm64-v8a', 'armeabi-v7a']; - - static const String deviceType = 'android'; - static const String timezone = 'Europe/Moscow'; - static const String locale = 'ru_RU'; - static const String appVersion = '26.10.1'; - static const String buildNumber = '6728'; -} diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart new file mode 100644 index 0000000..21a766d --- /dev/null +++ b/lib/core/storage/spoofing_service.dart @@ -0,0 +1,27 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class SpoofingService { + static const String hardcodedAppVersion = '26.8.1'; + static const int hardcodedBuildNumber = 6606; + + static Future?> getSpoofedSessionData() async { + final prefs = await SharedPreferences.getInstance(); + + final isEnabled = prefs.getBool('spoofing_enabled') ?? false; + if (!isEnabled) return null; + + return { + 'device_name': prefs.getString('spoof_devicename'), + 'os_version': prefs.getString('spoof_osversion'), + 'screen': prefs.getString('spoof_screen'), + 'timezone': prefs.getString('spoof_timezone'), + 'locale': prefs.getString('spoof_locale'), + 'device_id': prefs.getString('spoof_deviceid'), + 'device_type': prefs.getString('spoof_devicetype'), + 'app_version': prefs.getString('spoof_appversion') ?? hardcodedAppVersion, + 'arch': prefs.getString('spoof_arch') ?? 'arm64-v8a', + 'build_number': + prefs.getInt('spoof_buildnumber') ?? hardcodedBuildNumber, + }; + } +} diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index d5b7784..c64d751 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -12,7 +12,7 @@ import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; -import 'spoff_redacted_screen.dart'; +import '../profile/spoof_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../../main.dart'; @@ -522,7 +522,7 @@ class _LoginScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => const SpoffRedactedScreen(), + builder: (context) => const SpoofScreen(), ), ); }, diff --git a/lib/frontend/screens/auth/spoff_redacted_screen.dart b/lib/frontend/screens/auth/spoff_redacted_screen.dart deleted file mode 100644 index a553a59..0000000 --- a/lib/frontend/screens/auth/spoff_redacted_screen.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'dart:math'; -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:komet/core/config/spoof_data.dart'; -import 'package:komet/frontend/widgets/custom_notification.dart'; - -class SpoffRedactedScreen extends StatefulWidget { - const SpoffRedactedScreen({super.key}); - - @override - State createState() => _SpoffRedactedScreenState(); -} - -class _SpoffRedactedScreenState extends State { - final TextEditingController _deviceNameController = TextEditingController(); - final TextEditingController _osVersionController = TextEditingController(); - final TextEditingController _resolutionController = TextEditingController(); - final TextEditingController _deviceIdController = TextEditingController(); - final TextEditingController _architectureController = TextEditingController(); - - @override - void initState() { - super.initState(); - _loadSettings(); - } - - Future _loadSettings() async { - final prefs = await SharedPreferences.getInstance(); - - final random = Random(); - - setState(() { - _deviceNameController.text = - prefs.getString('spoof_device_name') ?? - SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; - _osVersionController.text = - prefs.getString('spoof_os_version') ?? - SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]; - _resolutionController.text = - prefs.getString('spoof_resolution') ?? - SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; - _deviceIdController.text = - prefs.getString('spoof_device_id') ?? - SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; - _architectureController.text = - prefs.getString('spoof_architecture') ?? - SpoofData.architectures[random.nextInt( - SpoofData.architectures.length, - )]; - }); - } - - Future _saveSettings() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('spoof_device_name', _deviceNameController.text); - await prefs.setString('spoof_os_version', _osVersionController.text); - await prefs.setString('spoof_resolution', _resolutionController.text); - await prefs.setString('spoof_device_id', _deviceIdController.text); - await prefs.setString('spoof_architecture', _architectureController.text); - - if (mounted) { - showCustomNotification(context, 'Настройки сохранены'); - } - } - - @override - void dispose() { - _deviceNameController.dispose(); - _osVersionController.dispose(); - _resolutionController.dispose(); - _deviceIdController.dispose(); - _architectureController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: cs.surface, - appBar: AppBar( - iconTheme: IconThemeData(color: cs.onSurface), - title: Text( - 'Подделка спуфа', - style: GoogleFonts.inter( - color: cs.onSurface, - fontWeight: FontWeight.w500, - ), - ), - backgroundColor: cs.surface, - actions: [ - IconButton( - icon: Icon(Icons.check, color: cs.primary), - onPressed: _saveSettings, - ), - ], - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - _buildTextField( - controller: TextEditingController(text: SpoofData.deviceType), - label: 'Тип устройства', - cs: cs, - readOnly: true, - ), - const SizedBox(height: 16), - _buildTextField( - controller: _deviceNameController, - label: 'Имя устройства', - cs: cs, - ), - const SizedBox(height: 16), - _buildTextField( - controller: _osVersionController, - label: 'Версия ОС', - cs: cs, - ), - const SizedBox(height: 16), - _buildTextField( - controller: _resolutionController, - label: 'Разрешение экрана', - cs: cs, - ), - const SizedBox(height: 16), - _buildTextField( - controller: TextEditingController(text: SpoofData.timezone), - label: 'Часовой пояс', - cs: cs, - readOnly: true, - ), - const SizedBox(height: 16), - _buildTextField( - controller: TextEditingController(text: SpoofData.locale), - label: 'Локаль', - cs: cs, - readOnly: true, - ), - const SizedBox(height: 16), - _buildTextField( - controller: _deviceIdController, - label: 'ID устройства', - cs: cs, - ), - const SizedBox(height: 16), - _buildTextField( - controller: TextEditingController(text: SpoofData.appVersion), - label: 'Версия приложения', - cs: cs, - readOnly: true, - ), - const SizedBox(height: 16), - _buildTextField( - controller: TextEditingController(text: SpoofData.buildNumber), - label: 'Build Number', - cs: cs, - readOnly: true, - ), - const SizedBox(height: 16), - _buildTextField( - controller: _architectureController, - label: 'Архитектура', - cs: cs, - ), - const SizedBox(height: 32), - ], - ), - ), - ); - } - - Widget _buildTextField({ - required TextEditingController controller, - required String label, - required ColorScheme cs, - bool readOnly = false, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: GoogleFonts.inter( - color: cs.onSurfaceVariant, - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - readOnly: readOnly, - style: GoogleFonts.inter( - color: readOnly ? cs.onSurfaceVariant : cs.onSurface, - fontSize: 15, - ), - decoration: InputDecoration( - filled: true, - fillColor: readOnly - ? cs.surfaceContainerHighest - : cs.surfaceContainerHigh, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - ), - ), - ], - ); - } -} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index ddaba8e..2389838 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import '../../../l10n/app_localizations.dart'; import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; @@ -143,7 +144,7 @@ class _SettingsTabState extends State { ), _SettingsItem( icon: Symbols.shield_lock, - label: 'Подделка данных', + label: AppLocalizations.of(context)!.profileMenuSpoof, onTap: () { Navigator.push( context, diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index accabc4..b5ca254 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -1,9 +1,18 @@ +import 'dart:async'; +import 'dart:io'; import 'dart:math'; + +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../../core/config/spoof_data.dart'; -import '../../widgets/custom_notification.dart'; + +import '../../../core/config/device_presets.dart'; +import '../../../core/storage/spoofing_service.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart'; + +enum SpoofingMethod { partial, full } class SpoofScreen extends StatefulWidget { const SpoofScreen({super.key}); @@ -13,158 +22,400 @@ class SpoofScreen extends StatefulWidget { } class _SpoofScreenState extends State { - final _deviceTypeController = TextEditingController(); + static const String _hardcodedVersion = SpoofingService.hardcodedAppVersion; + static const int _hardcodedBuildNumber = + SpoofingService.hardcodedBuildNumber; + + final _random = Random(); final _deviceNameController = TextEditingController(); final _osVersionController = TextEditingController(); final _screenController = TextEditingController(); final _timezoneController = TextEditingController(); final _localeController = TextEditingController(); - final _deviceLocaleController = TextEditingController(); final _deviceIdController = TextEditingController(); final _appVersionController = TextEditingController(); final _buildNumberController = TextEditingController(); - final _architectureController = TextEditingController(); - final _pushDeviceTypeController = TextEditingController(); - final _mtInstanceIdController = TextEditingController(); - final _clientSessionIdController = TextEditingController(); + String _selectedDeviceType = 'ANDROID'; + String _selectedArch = 'arm64-v8a'; + SpoofingMethod _selectedMethod = SpoofingMethod.partial; bool _isLoading = true; @override void initState() { super.initState(); - _loadSettings(); + _loadInitialData(); } - Future _loadSettings() async { - final prefs = await SharedPreferences.getInstance(); - final random = Random(); + String _generateDeviceId() { + final bytes = List.generate(8, (_) => _random.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + Future _loadInitialData() async { + setState(() => _isLoading = true); + final prefs = await SharedPreferences.getInstance(); + final isSpoofingEnabled = prefs.getBool('spoofing_enabled') ?? false; + + if (isSpoofingEnabled) { + _deviceNameController.text = prefs.getString('spoof_devicename') ?? ''; + _osVersionController.text = prefs.getString('spoof_osversion') ?? ''; + _screenController.text = prefs.getString('spoof_screen') ?? ''; + _timezoneController.text = prefs.getString('spoof_timezone') ?? ''; + _localeController.text = prefs.getString('spoof_locale') ?? ''; + _deviceIdController.text = prefs.getString('spoof_deviceid') ?? ''; + _appVersionController.text = + prefs.getString('spoof_appversion') ?? _hardcodedVersion; + _selectedArch = prefs.getString('spoof_arch') ?? 'arm64-v8a'; + _buildNumberController.text = + prefs.getInt('spoof_buildnumber')?.toString() ?? + '$_hardcodedBuildNumber'; + + String savedType = prefs.getString('spoof_devicetype') ?? 'ANDROID'; + if (savedType == 'WEB') savedType = 'ANDROID'; + _selectedDeviceType = savedType; + if (mounted) setState(() => _isLoading = false); + } else { + await _loadDeviceData(); + } + } + + Future _loadDeviceData() async { + setState(() => _isLoading = true); + + final deviceInfo = DeviceInfoPlugin(); + final pixelRatio = View.of(context).devicePixelRatio; + final size = View.of(context).physicalSize; + + _appVersionController.text = _hardcodedVersion; + _localeController.text = Platform.localeName.split('_').first; + + final dpi = (160 * pixelRatio).round(); + String densityBucket; + if (dpi >= 560) { + densityBucket = 'xxxhdpi'; + } else if (dpi >= 380) { + densityBucket = 'xxhdpi'; + } else if (dpi >= 280) { + densityBucket = 'xhdpi'; + } else if (dpi >= 200) { + densityBucket = 'hdpi'; + } else if (dpi >= 140) { + densityBucket = 'mdpi'; + } else { + densityBucket = 'ldpi'; + } + _screenController.text = + '$densityBucket ${dpi}dpi ${size.width.round()}x${size.height.round()}'; + + final prefs = await SharedPreferences.getInstance(); + var realDeviceId = prefs.getString('real_device_id'); + if (realDeviceId == null || realDeviceId.isEmpty) { + realDeviceId = _generateDeviceId(); + await prefs.setString('real_device_id', realDeviceId); + } + _deviceIdController.text = realDeviceId; + + try { + final timezoneInfo = await FlutterTimezone.getLocalTimezone(); + _timezoneController.text = timezoneInfo.identifier; + } catch (_) { + _timezoneController.text = 'Europe/Moscow'; + } + + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + _deviceNameController.text = + '${androidInfo.manufacturer} ${androidInfo.model}'; + _osVersionController.text = 'Android ${androidInfo.version.release}'; + _selectedDeviceType = 'ANDROID'; + _selectedArch = androidInfo.supportedAbis.isNotEmpty + ? androidInfo.supportedAbis.first + : 'arm64-v8a'; + _buildNumberController.text = '$_hardcodedBuildNumber'; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + _deviceNameController.text = iosInfo.name; + _osVersionController.text = + '${iosInfo.systemName} ${iosInfo.systemVersion}'; + _selectedDeviceType = 'IOS'; + _selectedArch = 'arm64'; + _buildNumberController.text = '$_hardcodedBuildNumber'; + } else { + await _applyGeneratedData(); + } + + if (mounted) setState(() => _isLoading = false); + } + + Future _applyGeneratedData() async { + final filteredPresets = devicePresets + .where( + (p) => p.deviceType != 'WEB' && p.deviceType == _selectedDeviceType, + ) + .toList(); + + if (filteredPresets.isEmpty) return; + + final preset = filteredPresets[_random.nextInt(filteredPresets.length)]; + await _applyPreset(preset); + } + + Future _applyPreset(DevicePreset preset) async { setState(() { - _deviceTypeController.text = prefs.getString('spoof_device_type') ?? SpoofData.deviceType; - _deviceNameController.text = prefs.getString('spoof_device_name') ?? - SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; - _osVersionController.text = prefs.getString('spoof_os_version') ?? - 'Android ${SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]}'; - _screenController.text = prefs.getString('spoof_screen') ?? - SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; - _timezoneController.text = prefs.getString('spoof_timezone') ?? SpoofData.timezone; - _localeController.text = prefs.getString('spoof_locale') ?? SpoofData.locale; - _deviceLocaleController.text = prefs.getString('spoof_device_locale') ?? 'ru'; - _deviceIdController.text = prefs.getString('spoof_device_id') ?? - SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; - _appVersionController.text = prefs.getString('spoof_app_version') ?? SpoofData.appVersion; - _buildNumberController.text = prefs.getString('spoof_build_number') ?? SpoofData.buildNumber; - _architectureController.text = prefs.getString('spoof_architecture') ?? - SpoofData.architectures[random.nextInt(SpoofData.architectures.length)]; - _pushDeviceTypeController.text = prefs.getString('spoof_push_device_type') ?? 'GCM'; - _mtInstanceIdController.text = prefs.getString('spoof_mt_instanceid') ?? - '550e8400-e29b-41d4-a716-446655440000'; - _clientSessionIdController.text = prefs.getString('spoof_client_session_id') ?? '42'; - _isLoading = false; + _deviceNameController.text = preset.deviceName; + _osVersionController.text = preset.osVersion; + _screenController.text = preset.screen; + _appVersionController.text = _hardcodedVersion; + _deviceIdController.text = _generateDeviceId(); + + _selectedDeviceType = preset.deviceType; + + if (preset.deviceType == 'ANDROID') { + _selectedArch = 'arm64-v8a'; + } else if (preset.deviceType == 'IOS') { + _selectedArch = 'arm64'; + } else { + _selectedArch = 'x86_64'; + } + _buildNumberController.text = '$_hardcodedBuildNumber'; + + if (_selectedMethod == SpoofingMethod.full) { + _timezoneController.text = preset.timezone; + _localeController.text = preset.locale; + } }); + + if (_selectedMethod == SpoofingMethod.partial) { + String timezone; + try { + final timezoneInfo = await FlutterTimezone.getLocalTimezone(); + timezone = timezoneInfo.identifier; + } catch (_) { + timezone = 'Europe/Moscow'; + } + final locale = Platform.localeName.split('_').first; + + if (mounted) { + setState(() { + _timezoneController.text = timezone; + _localeController.text = locale; + }); + } + } } - Future _saveSettings() async { + Future _saveSpoofingSettings() async { + if (!mounted) return; + final prefs = await SharedPreferences.getInstance(); - await prefs.setString('spoof_device_type', _deviceTypeController.text); - await prefs.setString('spoof_device_name', _deviceNameController.text); - await prefs.setString('spoof_os_version', _osVersionController.text); + + final oldValues = { + 'device_name': prefs.getString('spoof_devicename') ?? '', + 'os_version': prefs.getString('spoof_osversion') ?? '', + 'screen': prefs.getString('spoof_screen') ?? '', + 'timezone': prefs.getString('spoof_timezone') ?? '', + 'locale': prefs.getString('spoof_locale') ?? '', + 'device_id': prefs.getString('spoof_deviceid') ?? '', + 'device_type': prefs.getString('spoof_devicetype') ?? 'ANDROID', + 'arch': prefs.getString('spoof_arch') ?? '', + 'build_number': prefs.getInt('spoof_buildnumber')?.toString() ?? '', + }; + + final newValues = { + 'device_name': _deviceNameController.text, + 'os_version': _osVersionController.text, + 'screen': _screenController.text, + 'timezone': _timezoneController.text, + 'locale': _localeController.text, + 'device_id': _deviceIdController.text, + 'device_type': _selectedDeviceType, + 'arch': _selectedArch, + 'build_number': _buildNumberController.text, + }; + + final oldAppVersion = + prefs.getString('spoof_appversion') ?? _hardcodedVersion; + final newAppVersion = _appVersionController.text; + + bool otherDataChanged = false; + for (final key in oldValues.keys) { + if (oldValues[key] != newValues[key]) { + otherDataChanged = true; + break; + } + } + + final appVersionChanged = oldAppVersion != newAppVersion; + final isChangingAwayFromHardcoded = newAppVersion != _hardcodedVersion; + + if (appVersionChanged && isChangingAwayFromHardcoded) { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.spoofDialogUnsureTitle), + content: Text(l10n.spoofDialogUnsureContent), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.spoofDialogCancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.spoofDialogYes), + ), + ], + ), + ); + if (confirmed != true) return; + } + + if (appVersionChanged && !otherDataChanged) { + await _saveAllData(prefs); + if (mounted) Navigator.of(context).pop(); + return; + } + + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.spoofDialogApplyTitle), + content: Text(l10n.spoofDialogApplyContent), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.spoofDialogApplyDeny), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.spoofDialogApplyConfirm), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + await _saveAllData(prefs); + + try { + await api.disconnect(); + await api.connect(); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.spoofErrorApplyFailed(e.toString()), + ), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + Future _saveAllData(SharedPreferences prefs) async { + await prefs.setBool('spoofing_enabled', true); + await prefs.setString('spoof_devicename', _deviceNameController.text); + await prefs.setString('spoof_osversion', _osVersionController.text); await prefs.setString('spoof_screen', _screenController.text); await prefs.setString('spoof_timezone', _timezoneController.text); await prefs.setString('spoof_locale', _localeController.text); - await prefs.setString('spoof_device_locale', _deviceLocaleController.text); - await prefs.setString('spoof_device_id', _deviceIdController.text); - await prefs.setString('spoof_app_version', _appVersionController.text); - await prefs.setString('spoof_build_number', _buildNumberController.text); - await prefs.setString('spoof_architecture', _architectureController.text); - await prefs.setString('spoof_push_device_type', _pushDeviceTypeController.text); - await prefs.setString('spoof_mt_instanceid', _mtInstanceIdController.text); - await prefs.setString('spoof_client_session_id', _clientSessionIdController.text); - - if (mounted) { - showCustomNotification(context, 'Настройки сохранены'); - } + await prefs.setString('spoof_deviceid', _deviceIdController.text); + await prefs.setString('spoof_devicetype', _selectedDeviceType); + await prefs.setString('spoof_appversion', _appVersionController.text); + await prefs.setString('spoof_arch', _selectedArch); + await prefs.setInt( + 'spoof_buildnumber', + int.tryParse(_buildNumberController.text) ?? _hardcodedBuildNumber, + ); } - Future _randomizeAll() async { - final random = Random(); + void _generateNewDeviceId() { setState(() { - _deviceNameController.text = SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; - _osVersionController.text = 'Android ${SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]}'; - _screenController.text = SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; - _deviceIdController.text = SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; - _architectureController.text = SpoofData.architectures[random.nextInt(SpoofData.architectures.length)]; + _deviceIdController.text = _generateDeviceId(); }); - await _saveSettings(); - if (mounted) { - showCustomNotification(context, 'Данные рандомизированы'); - } } @override void dispose() { - _deviceTypeController.dispose(); _deviceNameController.dispose(); _osVersionController.dispose(); _screenController.dispose(); _timezoneController.dispose(); _localeController.dispose(); - _deviceLocaleController.dispose(); _deviceIdController.dispose(); _appVersionController.dispose(); _buildNumberController.dispose(); - _architectureController.dispose(); - _pushDeviceTypeController.dispose(); - _mtInstanceIdController.dispose(); - _clientSessionIdController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - - if (_isLoading) { - return Scaffold( - backgroundColor: cs.surface, - body: const Center(child: CircularProgressIndicator()), - ); - } - + final l10n = AppLocalizations.of(context)!; return Scaffold( - backgroundColor: cs.surface, - body: SafeArea( - bottom: false, - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter(child: _buildAppBar(context, cs)), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildSection(cs, [ - _buildField(cs, 'Тип устройства', _deviceTypeController), - _buildField(cs, 'Имя устройства', _deviceNameController), - _buildField(cs, 'Версия ОС', _osVersionController), - _buildField(cs, 'Разрешение экрана', _screenController), - _buildField(cs, 'Архитектура', _architectureController), - _buildField(cs, 'ID устройства', _deviceIdController), - _buildField(cs, 'Часовой пояс', _timezoneController), - _buildField(cs, 'Локаль', _localeController), - _buildField(cs, 'Локаль устройства', _deviceLocaleController), - _buildField(cs, 'Версия приложения', _appVersionController), - _buildField(cs, 'Build Number', _buildNumberController), - _buildField(cs, 'Push Device Type', _pushDeviceTypeController), - _buildField(cs, 'MT Instance ID', _mtInstanceIdController), - _buildField(cs, 'Client Session ID', _clientSessionIdController, isLast: true), - ]), + appBar: AppBar( + title: Text(l10n.spoofScreenTitle), + centerTitle: true, + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildInfoCard(), + const SizedBox(height: 16), + _buildSpoofingMethodCard(), + const SizedBox(height: 16), + _buildDeviceTypeCard(), + const SizedBox(height: 24), + _buildMainDataCard(), + const SizedBox(height: 16), + _buildRegionalDataCard(), + const SizedBox(height: 16), + _buildIdentifiersCard(), + ], ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), - child: _buildActionButtons(cs), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: _buildFloatingActionButtons(), + ); + } + + Widget _buildInfoCard() { + final l10n = AppLocalizations.of(context)!; + return Card( + color: Theme.of( + context, + ).colorScheme.secondaryContainer.withValues(alpha: 0.5), + elevation: 0, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.touch_app, + size: 18, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + l10n.spoofInfoHint, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), ), ), ], @@ -173,161 +424,388 @@ class _SpoofScreenState extends State { ); } - Widget _buildAppBar(BuildContext context, ColorScheme cs) { + Widget _buildSpoofingMethodCard() { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + Widget descriptionWidget; + + if (_selectedMethod == SpoofingMethod.partial) { + descriptionWidget = _buildDescriptionTile( + icon: Icons.check_circle_outline, + color: Colors.green.shade700, + text: l10n.spoofMethodPartialDescription, + ); + } else { + descriptionWidget = _buildDescriptionTile( + icon: Icons.warning_amber_rounded, + color: theme.colorScheme.error, + text: l10n.spoofMethodFullDescription, + ); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Text(l10n.spoofMethodTitle, style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + SegmentedButton( + style: SegmentedButton.styleFrom(shape: const StadiumBorder()), + segments: [ + ButtonSegment( + value: SpoofingMethod.partial, + label: Text(l10n.spoofMethodPartial), + icon: const Icon(Icons.security_outlined), + ), + ButtonSegment( + value: SpoofingMethod.full, + label: Text(l10n.spoofMethodFull), + icon: const Icon(Icons.public_outlined), + ), + ], + selected: {_selectedMethod}, + onSelectionChanged: (s) => + setState(() => _selectedMethod = s.first), + ), + const SizedBox(height: 12), + descriptionWidget, + ], + ), + ), + ); + } + + Widget _buildDeviceTypeCard() { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.spoofDeviceTypeTitle, style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + _buildDescriptionTile( + icon: Icons.info_outline, + color: theme.colorScheme.primary, + text: l10n.spoofDeviceTypeDescription, + ), + const SizedBox(height: 12), + _buildChipSelector( + options: const [ + _ChipOption('ANDROID', 'ANDROID', Icons.android_outlined), + _ChipOption('IOS', 'iOS', Icons.phone_iphone_outlined), + _ChipOption( + 'DESKTOP', + 'Desktop', + Icons.desktop_windows_outlined, + ), + ], + selected: _selectedDeviceType, + onSelected: (value) { + setState(() { + _selectedDeviceType = value; + if (value == 'ANDROID') { + _selectedArch = 'arm64-v8a'; + } else if (value == 'IOS') { + _selectedArch = 'arm64'; + } else { + _selectedArch = 'x86_64'; + } + }); + }, + ), + ], + ), + ), + ); + } + + Widget _buildDescriptionTile({ + required IconData icon, + required Color color, + required String text, + }) { + return ListTile( + leading: Icon(icon, color: color), + contentPadding: EdgeInsets.zero, + title: Text( + text, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildMainDataCard() { + final l10n = AppLocalizations.of(context)!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, l10n.spoofMainSectionTitle), + TextField( + controller: _deviceNameController, + decoration: _inputDecoration( + l10n.spoofFieldDeviceName, + Icons.smartphone_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _osVersionController, + decoration: _inputDecoration( + l10n.spoofFieldOsVersion, + Icons.layers_outlined, + ), + ), + ], + ), + ), + ); + } + + Widget _buildRegionalDataCard() { + final l10n = AppLocalizations.of(context)!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, l10n.spoofRegionalSectionTitle), + TextField( + controller: _screenController, + decoration: _inputDecoration( + l10n.spoofFieldScreen, + Icons.fullscreen_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _timezoneController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldTimezone, + Icons.public_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _localeController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldLocale, + Icons.language_outlined, + ), + ), + ], + ), + ), + ); + } + + Widget _buildIdentifiersCard() { + final l10n = AppLocalizations.of(context)!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, l10n.spoofIdentifiersSectionTitle), + _buildDescriptionTile( + icon: Icons.info_outline, + color: Theme.of(context).colorScheme.tertiary, + text: l10n.spoofIdentifiersDescription, + ), + const SizedBox(height: 12), + TextField( + controller: _deviceIdController, + decoration: + _inputDecoration(l10n.spoofFieldDeviceId, Icons.tag_outlined) + .copyWith( + suffixIcon: IconButton( + icon: const Icon(Icons.autorenew_outlined), + tooltip: l10n.spoofRegenerateIdTooltip, + onPressed: _generateNewDeviceId, + ), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _appVersionController, + decoration: _inputDecoration( + l10n.spoofFieldAppVersion, + Icons.info_outline_rounded, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _buildNumberController, + keyboardType: TextInputType.number, + decoration: _inputDecoration( + l10n.spoofFieldBuildNumber, + Icons.numbers_outlined, + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + l10n.spoofFieldArchitecture, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + _buildChipSelector( + options: const [ + _ChipOption('arm64-v8a', 'arm64-v8a', Icons.memory_outlined), + _ChipOption( + 'armeabi-v7a', + 'armeabi-v7a', + Icons.memory_outlined, + ), + _ChipOption('x86', 'x86', Icons.memory_outlined), + _ChipOption('x86_64', 'x86_64', Icons.memory_outlined), + _ChipOption('arm64', 'arm64', Icons.memory_outlined), + ], + selected: _selectedArch, + onSelected: (value) => + setState(() => _selectedArch = value), + ), + ], + ), + ), + ); + } + + InputDecoration _inputDecoration(String label, IconData icon) { + return InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, + ); + } + + Widget _buildChipSelector({ + required List<_ChipOption> options, + required T selected, + required ValueChanged onSelected, + }) { + final cs = Theme.of(context).colorScheme; + return Wrap( + spacing: 8, + runSpacing: 8, + children: options.map((opt) { + final isSelected = opt.value == selected; + return ChoiceChip( + label: Text(opt.label), + avatar: isSelected + ? Icon(Icons.check, size: 18, color: cs.onSecondaryContainer) + : (opt.icon != null + ? Icon(opt.icon, size: 18, color: cs.onSurfaceVariant) + : null), + selected: isSelected, + showCheckmark: false, + onSelected: (_) => onSelected(opt.value), + labelStyle: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isSelected ? cs.onSecondaryContainer : cs.onSurface, + ), + backgroundColor: cs.surfaceContainerHighest, + selectedColor: cs.secondaryContainer, + side: BorderSide( + color: isSelected ? Colors.transparent : cs.outlineVariant, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + }).toList(), + ); + } + + Widget _buildFloatingActionButtons() { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ - IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface, size: 24, weight: 400), - onPressed: () => Navigator.pop(context), + Expanded( + flex: 1, + child: FilledButton.tonal( + onPressed: _applyGeneratedData, + onLongPress: _loadDeviceData, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + shape: const StadiumBorder(), + ), + child: Text(l10n.spoofButtonGenerate), + ), ), - const SizedBox(width: 4), - Text( - 'Подделка данных', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + const SizedBox(width: 12), + Expanded( + flex: 1, + child: FilledButton( + onPressed: _saveSpoofingSettings, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + shape: const StadiumBorder(), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.save_alt_outlined), + const SizedBox(width: 8), + Text(l10n.spoofButtonApply), + ], + ), ), ), ], ), ); } +} +class _ChipOption { + final T value; + final String label; + final IconData? icon; - - Widget _buildSection(ColorScheme cs, List children) { - return Container( - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - ), - child: Column(children: children), - ); - } - - Widget _buildField( - ColorScheme cs, - String label, - TextEditingController controller, { - bool isLast = false, - }) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 140, - child: Text( - label, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: controller, - style: TextStyle( - color: cs.onSurface, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - decoration: InputDecoration( - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - isDense: true, - ), - ), - ), - ], - ), - ), - if (!isLast) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), - ), - ), - ], - ); - } - - Widget _buildActionButtons(ColorScheme cs) { - return Row( - children: [ - Expanded( - child: SizedBox( - height: 48, - child: OutlinedButton( - onPressed: _randomizeAll, - style: OutlinedButton.styleFrom( - foregroundColor: cs.onSurface, - side: BorderSide(color: cs.outline, width: 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Symbols.shuffle, size: 18, weight: 400), - const SizedBox(width: 6), - const Text( - 'Рандомизировать', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: SizedBox( - height: 48, - child: FilledButton( - onPressed: _saveSettings, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - child: const Text( - 'Сохранить', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ], - ); - } + const _ChipOption(this.value, this.label, [this.icon]); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ae9b610..c047aaa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12,7 +12,7 @@ "loginEdit": "Change", "loginDone": "Done", "loginReadTermsNotification": "Please read the terms of use first", - "loginSpoofRedacted": "Spoof redaction", + "loginSpoofRedacted": "Spoofing", "loginProxy": "Proxy", "loginChangeServer": "Change server", "serverSettingsTitle": "Server", @@ -54,5 +54,49 @@ "proxyApply": "Apply and reconnect", "proxyDisable": "Disable proxy", "proxySettingsSaved": "Proxy settings applied", - "proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)" + "proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)", + + "spoofScreenTitle": "Session spoofing", + "spoofInfoHint": "Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.", + "spoofMethodTitle": "Spoofing method", + "spoofMethodPartial": "Partial", + "spoofMethodFull": "Full", + "spoofMethodPartialDescription": "Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.", + "spoofMethodFullDescription": "All data including timezone and locale is generated randomly. Use this method at your own risk!", + "spoofDeviceTypeTitle": "Device type", + "spoofDeviceTypeDescription": "Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.", + "spoofDeviceTypeLabel": "Device type", + "spoofMainSectionTitle": "Main data", + "spoofFieldDeviceName": "Device name", + "spoofFieldOsVersion": "OS version", + "spoofRegionalSectionTitle": "Regional data", + "spoofFieldScreen": "Screen resolution", + "spoofFieldTimezone": "Timezone", + "spoofFieldLocale": "Locale", + "spoofIdentifiersSectionTitle": "Identifiers", + "spoofIdentifiersDescription": "mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.", + "spoofFieldDeviceId": "Device ID", + "spoofRegenerateIdTooltip": "Generate a new ID", + "spoofFieldAppVersion": "App version", + "spoofFieldBuildNumber": "Build number", + "spoofFieldArchitecture": "Architecture", + "spoofButtonGenerate": "Generate", + "spoofButtonApply": "Apply", + "spoofDialogUnsureTitle": "Are you sure?", + "spoofDialogUnsureContent": "The app may become unstable due to API incompatibility", + "spoofDialogCancel": "Cancel", + "spoofDialogYes": "Yes", + "spoofDialogApplyTitle": "Apply settings?", + "spoofDialogApplyContent": "Need to reconnect the app, ok?", + "spoofDialogApplyDeny": "No", + "spoofDialogApplyConfirm": "Ok!", + "spoofErrorApplyFailed": "Failed to apply settings: {error}", + "@spoofErrorApplyFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "profileMenuSpoof": "Spoofing" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6741f70..15fbdb0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -62,7 +62,8 @@ import 'app_localizations_ru.dart'; /// be consistent with the languages listed in the AppLocalizations.supportedLocales /// property. abstract class AppLocalizations { - AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -70,7 +71,8 @@ abstract class AppLocalizations { return Localizations.of(context, AppLocalizations); } - static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); /// A list of this localizations delegate along with the default localizations /// delegates. @@ -82,17 +84,18 @@ abstract class AppLocalizations { /// Additional delegates can be added by appending to this list in /// MaterialApp. This list does not have to be used at all if a custom list /// of delegates is preferred or required. - static const List> localizationsDelegates = >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ Locale('en'), - Locale('ru') + Locale('ru'), ]; /// No description provided for @loginTitle. @@ -170,7 +173,7 @@ abstract class AppLocalizations { /// No description provided for @loginSpoofRedacted. /// /// In en, this message translates to: - /// **'Spoof redaction'** + /// **'Spoofing'** String get loginSpoofRedacted; /// No description provided for @loginProxy. @@ -376,9 +379,226 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Enter a valid proxy host and port (1–65535)'** String get proxyInvalidHostOrPort; + + /// No description provided for @spoofScreenTitle. + /// + /// In en, this message translates to: + /// **'Session spoofing'** + String get spoofScreenTitle; + + /// No description provided for @spoofInfoHint. + /// + /// In en, this message translates to: + /// **'Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.'** + String get spoofInfoHint; + + /// No description provided for @spoofMethodTitle. + /// + /// In en, this message translates to: + /// **'Spoofing method'** + String get spoofMethodTitle; + + /// No description provided for @spoofMethodPartial. + /// + /// In en, this message translates to: + /// **'Partial'** + String get spoofMethodPartial; + + /// No description provided for @spoofMethodFull. + /// + /// In en, this message translates to: + /// **'Full'** + String get spoofMethodFull; + + /// No description provided for @spoofMethodPartialDescription. + /// + /// In en, this message translates to: + /// **'Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.'** + String get spoofMethodPartialDescription; + + /// No description provided for @spoofMethodFullDescription. + /// + /// In en, this message translates to: + /// **'All data including timezone and locale is generated randomly. Use this method at your own risk!'** + String get spoofMethodFullDescription; + + /// No description provided for @spoofDeviceTypeTitle. + /// + /// In en, this message translates to: + /// **'Device type'** + String get spoofDeviceTypeTitle; + + /// No description provided for @spoofDeviceTypeDescription. + /// + /// In en, this message translates to: + /// **'Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.'** + String get spoofDeviceTypeDescription; + + /// No description provided for @spoofDeviceTypeLabel. + /// + /// In en, this message translates to: + /// **'Device type'** + String get spoofDeviceTypeLabel; + + /// No description provided for @spoofMainSectionTitle. + /// + /// In en, this message translates to: + /// **'Main data'** + String get spoofMainSectionTitle; + + /// No description provided for @spoofFieldDeviceName. + /// + /// In en, this message translates to: + /// **'Device name'** + String get spoofFieldDeviceName; + + /// No description provided for @spoofFieldOsVersion. + /// + /// In en, this message translates to: + /// **'OS version'** + String get spoofFieldOsVersion; + + /// No description provided for @spoofRegionalSectionTitle. + /// + /// In en, this message translates to: + /// **'Regional data'** + String get spoofRegionalSectionTitle; + + /// No description provided for @spoofFieldScreen. + /// + /// In en, this message translates to: + /// **'Screen resolution'** + String get spoofFieldScreen; + + /// No description provided for @spoofFieldTimezone. + /// + /// In en, this message translates to: + /// **'Timezone'** + String get spoofFieldTimezone; + + /// No description provided for @spoofFieldLocale. + /// + /// In en, this message translates to: + /// **'Locale'** + String get spoofFieldLocale; + + /// No description provided for @spoofIdentifiersSectionTitle. + /// + /// In en, this message translates to: + /// **'Identifiers'** + String get spoofIdentifiersSectionTitle; + + /// No description provided for @spoofIdentifiersDescription. + /// + /// In en, this message translates to: + /// **'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'** + String get spoofIdentifiersDescription; + + /// No description provided for @spoofFieldDeviceId. + /// + /// In en, this message translates to: + /// **'Device ID'** + String get spoofFieldDeviceId; + + /// No description provided for @spoofRegenerateIdTooltip. + /// + /// In en, this message translates to: + /// **'Generate a new ID'** + String get spoofRegenerateIdTooltip; + + /// No description provided for @spoofFieldAppVersion. + /// + /// In en, this message translates to: + /// **'App version'** + String get spoofFieldAppVersion; + + /// No description provided for @spoofFieldBuildNumber. + /// + /// In en, this message translates to: + /// **'Build number'** + String get spoofFieldBuildNumber; + + /// No description provided for @spoofFieldArchitecture. + /// + /// In en, this message translates to: + /// **'Architecture'** + String get spoofFieldArchitecture; + + /// No description provided for @spoofButtonGenerate. + /// + /// In en, this message translates to: + /// **'Generate'** + String get spoofButtonGenerate; + + /// No description provided for @spoofButtonApply. + /// + /// In en, this message translates to: + /// **'Apply'** + String get spoofButtonApply; + + /// No description provided for @spoofDialogUnsureTitle. + /// + /// In en, this message translates to: + /// **'Are you sure?'** + String get spoofDialogUnsureTitle; + + /// No description provided for @spoofDialogUnsureContent. + /// + /// In en, this message translates to: + /// **'The app may become unstable due to API incompatibility'** + String get spoofDialogUnsureContent; + + /// No description provided for @spoofDialogCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get spoofDialogCancel; + + /// No description provided for @spoofDialogYes. + /// + /// In en, this message translates to: + /// **'Yes'** + String get spoofDialogYes; + + /// No description provided for @spoofDialogApplyTitle. + /// + /// In en, this message translates to: + /// **'Apply settings?'** + String get spoofDialogApplyTitle; + + /// No description provided for @spoofDialogApplyContent. + /// + /// In en, this message translates to: + /// **'Need to reconnect the app, ok?'** + String get spoofDialogApplyContent; + + /// No description provided for @spoofDialogApplyDeny. + /// + /// In en, this message translates to: + /// **'No'** + String get spoofDialogApplyDeny; + + /// No description provided for @spoofDialogApplyConfirm. + /// + /// In en, this message translates to: + /// **'Ok!'** + String get spoofDialogApplyConfirm; + + /// No description provided for @spoofErrorApplyFailed. + /// + /// In en, this message translates to: + /// **'Failed to apply settings: {error}'** + String spoofErrorApplyFailed(String error); + + /// No description provided for @profileMenuSpoof. + /// + /// In en, this message translates to: + /// **'Spoofing'** + String get profileMenuSpoof; } -class _AppLocalizationsDelegate extends LocalizationsDelegate { +class _AppLocalizationsDelegate + extends LocalizationsDelegate { const _AppLocalizationsDelegate(); @override @@ -387,25 +607,26 @@ class _AppLocalizationsDelegate extends LocalizationsDelegate } @override - bool isSupported(Locale locale) => ['en', 'ru'].contains(locale.languageCode); + bool isSupported(Locale locale) => + ['en', 'ru'].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; } AppLocalizations lookupAppLocalizations(Locale locale) { - - // Lookup logic when only language code is specified. switch (locale.languageCode) { - case 'en': return AppLocalizationsEn(); - case 'ru': return AppLocalizationsRu(); + case 'en': + return AppLocalizationsEn(); + case 'ru': + return AppLocalizationsRu(); } throw FlutterError( 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' 'an issue with the localizations generation tool. Please file an issue ' 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.' + 'that was used.', ); } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 68d2d21..4422b8f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -12,7 +12,8 @@ class AppLocalizationsEn extends AppLocalizations { String get loginTitle => 'Sign in to Komet'; @override - String get loginSubtitle => 'Check your country code and enter your\nphone number.'; + String get loginSubtitle => + 'Check your country code and enter your\nphone number.'; @override String get loginCountry => 'Country'; @@ -45,7 +46,7 @@ class AppLocalizationsEn extends AppLocalizations { String get loginReadTermsNotification => 'Please read the terms of use first'; @override - String get loginSpoofRedacted => 'Spoof redaction'; + String get loginSpoofRedacted => 'Spoofing'; @override String get loginProxy => 'Proxy'; @@ -102,7 +103,8 @@ class AppLocalizationsEn extends AppLocalizations { String get selectCountrySearchHint => 'Search countries…'; @override - String get codeConfirmationSmsSent => 'We sent an SMS with a verification code to your phone number.'; + String get codeConfirmationSmsSent => + 'We sent an SMS with a verification code to your phone number.'; @override String codeResendInSeconds(int seconds) { @@ -149,5 +151,122 @@ class AppLocalizationsEn extends AppLocalizations { String get proxySettingsSaved => 'Proxy settings applied'; @override - String get proxyInvalidHostOrPort => 'Enter a valid proxy host and port (1–65535)'; + String get proxyInvalidHostOrPort => + 'Enter a valid proxy host and port (1–65535)'; + + @override + String get spoofScreenTitle => 'Session spoofing'; + + @override + String get spoofInfoHint => + 'Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.'; + + @override + String get spoofMethodTitle => 'Spoofing method'; + + @override + String get spoofMethodPartial => 'Partial'; + + @override + String get spoofMethodFull => 'Full'; + + @override + String get spoofMethodPartialDescription => + 'Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.'; + + @override + String get spoofMethodFullDescription => + 'All data including timezone and locale is generated randomly. Use this method at your own risk!'; + + @override + String get spoofDeviceTypeTitle => 'Device type'; + + @override + String get spoofDeviceTypeDescription => + 'Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.'; + + @override + String get spoofDeviceTypeLabel => 'Device type'; + + @override + String get spoofMainSectionTitle => 'Main data'; + + @override + String get spoofFieldDeviceName => 'Device name'; + + @override + String get spoofFieldOsVersion => 'OS version'; + + @override + String get spoofRegionalSectionTitle => 'Regional data'; + + @override + String get spoofFieldScreen => 'Screen resolution'; + + @override + String get spoofFieldTimezone => 'Timezone'; + + @override + String get spoofFieldLocale => 'Locale'; + + @override + String get spoofIdentifiersSectionTitle => 'Identifiers'; + + @override + String get spoofIdentifiersDescription => + 'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'; + + @override + String get spoofFieldDeviceId => 'Device ID'; + + @override + String get spoofRegenerateIdTooltip => 'Generate a new ID'; + + @override + String get spoofFieldAppVersion => 'App version'; + + @override + String get spoofFieldBuildNumber => 'Build number'; + + @override + String get spoofFieldArchitecture => 'Architecture'; + + @override + String get spoofButtonGenerate => 'Generate'; + + @override + String get spoofButtonApply => 'Apply'; + + @override + String get spoofDialogUnsureTitle => 'Are you sure?'; + + @override + String get spoofDialogUnsureContent => + 'The app may become unstable due to API incompatibility'; + + @override + String get spoofDialogCancel => 'Cancel'; + + @override + String get spoofDialogYes => 'Yes'; + + @override + String get spoofDialogApplyTitle => 'Apply settings?'; + + @override + String get spoofDialogApplyContent => 'Need to reconnect the app, ok?'; + + @override + String get spoofDialogApplyDeny => 'No'; + + @override + String get spoofDialogApplyConfirm => 'Ok!'; + + @override + String spoofErrorApplyFailed(String error) { + return 'Failed to apply settings: $error'; + } + + @override + String get profileMenuSpoof => 'Spoofing'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7f58d45..6648921 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -12,7 +12,8 @@ class AppLocalizationsRu extends AppLocalizations { String get loginTitle => 'Войдите в Komet'; @override - String get loginSubtitle => 'Проверьте код страны и введите свой\nномер телефона.'; + String get loginSubtitle => + 'Проверьте код страны и введите свой\nномер телефона.'; @override String get loginCountry => 'Страна'; @@ -42,10 +43,11 @@ class AppLocalizationsRu extends AppLocalizations { String get loginDone => 'Готово'; @override - String get loginReadTermsNotification => 'Сначала прочитайте условия использования'; + String get loginReadTermsNotification => + 'Сначала прочитайте условия использования'; @override - String get loginSpoofRedacted => 'Подделка спуфа'; + String get loginSpoofRedacted => 'Подмена данных'; @override String get loginProxy => 'Прокси'; @@ -69,7 +71,8 @@ class AppLocalizationsRu extends AppLocalizations { String get serverUseDefault => 'Сбросить к умолчанию'; @override - String get serverInvalidHostOrPort => 'Укажите корректный хост и порт (1–65535)'; + String get serverInvalidHostOrPort => + 'Укажите корректный хост и порт (1–65535)'; @override String get serverSettingsSaved => 'Настройки сервера применены'; @@ -102,7 +105,8 @@ class AppLocalizationsRu extends AppLocalizations { String get selectCountrySearchHint => 'Поиск страны…'; @override - String get codeConfirmationSmsSent => 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.'; + String get codeConfirmationSmsSent => + 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.'; @override String codeResendInSeconds(int seconds) { @@ -149,5 +153,122 @@ class AppLocalizationsRu extends AppLocalizations { String get proxySettingsSaved => 'Настройки прокси применены'; @override - String get proxyInvalidHostOrPort => 'Укажите корректный хост и порт прокси (1–65535)'; + String get proxyInvalidHostOrPort => + 'Укажите корректный хост и порт прокси (1–65535)'; + + @override + String get spoofScreenTitle => 'Подмена данных сессии'; + + @override + String get spoofInfoHint => + 'Нажмите \"Сгенерировать\":\n• Короткое нажатие: случайный пресет.\n• Длинное нажатие: реальные данные.'; + + @override + String get spoofMethodTitle => 'Метод подмены'; + + @override + String get spoofMethodPartial => 'Частичный'; + + @override + String get spoofMethodFull => 'Полный'; + + @override + String get spoofMethodPartialDescription => + 'Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.'; + + @override + String get spoofMethodFullDescription => + 'Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!'; + + @override + String get spoofDeviceTypeTitle => 'Тип устройства'; + + @override + String get spoofDeviceTypeDescription => + 'Выберите тип устройства для генерации пресетов. При нажатии \"Сгенерировать\" будут использоваться только пресеты выбранного типа.'; + + @override + String get spoofDeviceTypeLabel => 'Тип устройства'; + + @override + String get spoofMainSectionTitle => 'Основные данные'; + + @override + String get spoofFieldDeviceName => 'Имя устройства'; + + @override + String get spoofFieldOsVersion => 'Версия ОС'; + + @override + String get spoofRegionalSectionTitle => 'Региональные данные'; + + @override + String get spoofFieldScreen => 'Разрешение экрана'; + + @override + String get spoofFieldTimezone => 'Часовой пояс'; + + @override + String get spoofFieldLocale => 'Локаль'; + + @override + String get spoofIdentifiersSectionTitle => 'Идентификаторы'; + + @override + String get spoofIdentifiersDescription => + 'mt_instanceid и clientSessionId генерируются автоматически при каждом запуске приложения. Изменить можно только Device ID.'; + + @override + String get spoofFieldDeviceId => 'ID Устройства'; + + @override + String get spoofRegenerateIdTooltip => 'Сгенерировать новый ID'; + + @override + String get spoofFieldAppVersion => 'Версия приложения'; + + @override + String get spoofFieldBuildNumber => 'Build Number'; + + @override + String get spoofFieldArchitecture => 'Архитектура'; + + @override + String get spoofButtonGenerate => 'Сгенерировать'; + + @override + String get spoofButtonApply => 'Применить'; + + @override + String get spoofDialogUnsureTitle => 'Ты уверен?'; + + @override + String get spoofDialogUnsureContent => + 'Приложение может начать работать нестабильно из-за несовместимости API'; + + @override + String get spoofDialogCancel => 'Отмена'; + + @override + String get spoofDialogYes => 'Да'; + + @override + String get spoofDialogApplyTitle => 'Применить настройки?'; + + @override + String get spoofDialogApplyContent => 'Нужно перезайти в приложение, ок?'; + + @override + String get spoofDialogApplyDeny => 'Не'; + + @override + String get spoofDialogApplyConfirm => 'Ок!'; + + @override + String spoofErrorApplyFailed(String error) { + return 'Ошибка при применении настроек: $error'; + } + + @override + String get profileMenuSpoof => 'Подмена данных'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index babcc9e..2efbabb 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -12,7 +12,7 @@ "loginEdit": "Изменить", "loginDone": "Готово", "loginReadTermsNotification": "Сначала прочитайте условия использования", - "loginSpoofRedacted": "Подделка спуфа", + "loginSpoofRedacted": "Подмена данных", "loginProxy": "Прокси", "loginChangeServer": "Смена сервера", "serverSettingsTitle": "Сервер", @@ -54,5 +54,49 @@ "proxyApply": "Применить и переподключиться", "proxyDisable": "Отключить прокси", "proxySettingsSaved": "Настройки прокси применены", - "proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)" + "proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)", + + "spoofScreenTitle": "Подмена данных сессии", + "spoofInfoHint": "Нажмите \"Сгенерировать\":\n• Короткое нажатие: случайный пресет.\n• Длинное нажатие: реальные данные.", + "spoofMethodTitle": "Метод подмены", + "spoofMethodPartial": "Частичный", + "spoofMethodFull": "Полный", + "spoofMethodPartialDescription": "Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.", + "spoofMethodFullDescription": "Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!", + "spoofDeviceTypeTitle": "Тип устройства", + "spoofDeviceTypeDescription": "Выберите тип устройства для генерации пресетов. При нажатии \"Сгенерировать\" будут использоваться только пресеты выбранного типа.", + "spoofDeviceTypeLabel": "Тип устройства", + "spoofMainSectionTitle": "Основные данные", + "spoofFieldDeviceName": "Имя устройства", + "spoofFieldOsVersion": "Версия ОС", + "spoofRegionalSectionTitle": "Региональные данные", + "spoofFieldScreen": "Разрешение экрана", + "spoofFieldTimezone": "Часовой пояс", + "spoofFieldLocale": "Локаль", + "spoofIdentifiersSectionTitle": "Идентификаторы", + "spoofIdentifiersDescription": "mt_instanceid и clientSessionId генерируются автоматически при каждом запуске приложения. Изменить можно только Device ID.", + "spoofFieldDeviceId": "ID Устройства", + "spoofRegenerateIdTooltip": "Сгенерировать новый ID", + "spoofFieldAppVersion": "Версия приложения", + "spoofFieldBuildNumber": "Build Number", + "spoofFieldArchitecture": "Архитектура", + "spoofButtonGenerate": "Сгенерировать", + "spoofButtonApply": "Применить", + "spoofDialogUnsureTitle": "Ты уверен?", + "spoofDialogUnsureContent": "Приложение может начать работать нестабильно из-за несовместимости API", + "spoofDialogCancel": "Отмена", + "spoofDialogYes": "Да", + "spoofDialogApplyTitle": "Применить настройки?", + "spoofDialogApplyContent": "Нужно перезайти в приложение, ок?", + "spoofDialogApplyDeny": "Не", + "spoofDialogApplyConfirm": "Ок!", + "spoofErrorApplyFailed": "Ошибка при применении настроек: {error}", + "@spoofErrorApplyFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "profileMenuSpoof": "Подмена данных" } From 530621c798850088d21901959188898d389c44af Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 11 Apr 2026 00:25:15 +0300 Subject: [PATCH 074/323] fix(spoof): trim preset locale to 2-letter code --- lib/frontend/screens/profile/spoof_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index b5ca254..b8bd257 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -182,7 +182,7 @@ class _SpoofScreenState extends State { if (_selectedMethod == SpoofingMethod.full) { _timezoneController.text = preset.timezone; - _localeController.text = preset.locale; + _localeController.text = preset.locale.split(RegExp(r'[-_]')).first; } }); From 8965e81aacd8f2a4102e3d91c2e86d243a0b4d97 Mon Sep 17 00:00:00 2001 From: klockky <108465095+klockky@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:46:34 +0300 Subject: [PATCH 075/323] Add GitHub Actions workflow for Android build This workflow builds the Android application, including generating APKs and an AAB for release. --- .github/workflows/build-android.yml | 91 +++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/build-android.yml diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml new file mode 100644 index 0000000..380cd38 --- /dev/null +++ b/.github/workflows/build-android.yml @@ -0,0 +1,91 @@ +name: Build Android + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'lib/**' + - 'android/**' + - 'pubspec.yaml' + - '.github/workflows/build-android.yml' + pull_request: + paths: + - 'lib/**' + - 'android/**' + - 'pubspec.yaml' + - '.github/workflows/build-android.yml' + +jobs: + build-android: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v3 + + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + channel: 'stable' + + - name: Get dependencies + run: flutter pub get + + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties + + - name: Build Universal APK + run: flutter build apk --release + + - name: Build Split APKs + run: flutter build apk --release --split-per-abi + + - name: Upload Universal APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-universal + path: build/app/outputs/flutter-apk/app-release.apk + retention-days: 30 + + - name: Upload arm64-v8a APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-arm64-v8a + path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk + retention-days: 30 + + - name: Upload armeabi-v7a APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-armeabi-v7a + path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk + retention-days: 30 + + - name: Upload x86_64 APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-x86_64 + path: build/app/outputs/flutter-apk/app-x86_64-release.apk + retention-days: 30 + + - name: Build App Bundle + run: flutter build appbundle --release + + - name: Upload App Bundle artifact + uses: actions/upload-artifact@v4 + with: + name: komet-android-aab + path: build/app/outputs/bundle/release/app-release.aab + retention-days: 30 From 8087d55f043d9859b3f699943bb353eebe569e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sun, 12 Apr 2026 12:18:12 +0700 Subject: [PATCH 076/323] =?UTF-8?q?=D0=BD=D0=B5=D0=BA=D0=BE=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/storage/spoofing_service.dart | 5 +- .../screens/profile/spoof_screen.dart | 70 +++++-------------- 2 files changed, 18 insertions(+), 57 deletions(-) diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index 21a766d..b58cce2 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -18,10 +18,9 @@ class SpoofingService { 'locale': prefs.getString('spoof_locale'), 'device_id': prefs.getString('spoof_deviceid'), 'device_type': prefs.getString('spoof_devicetype'), - 'app_version': prefs.getString('spoof_appversion') ?? hardcodedAppVersion, + 'app_version': hardcodedAppVersion, 'arch': prefs.getString('spoof_arch') ?? 'arm64-v8a', - 'build_number': - prefs.getInt('spoof_buildnumber') ?? hardcodedBuildNumber, + 'build_number': hardcodedBuildNumber, }; } } diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index b8bd257..ff16a0c 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -23,8 +23,7 @@ class SpoofScreen extends StatefulWidget { class _SpoofScreenState extends State { static const String _hardcodedVersion = SpoofingService.hardcodedAppVersion; - static const int _hardcodedBuildNumber = - SpoofingService.hardcodedBuildNumber; + static const int _hardcodedBuildNumber = SpoofingService.hardcodedBuildNumber; final _random = Random(); final _deviceNameController = TextEditingController(); @@ -219,7 +218,6 @@ class _SpoofScreenState extends State { 'device_id': prefs.getString('spoof_deviceid') ?? '', 'device_type': prefs.getString('spoof_devicetype') ?? 'ANDROID', 'arch': prefs.getString('spoof_arch') ?? '', - 'build_number': prefs.getInt('spoof_buildnumber')?.toString() ?? '', }; final newValues = { @@ -231,13 +229,8 @@ class _SpoofScreenState extends State { 'device_id': _deviceIdController.text, 'device_type': _selectedDeviceType, 'arch': _selectedArch, - 'build_number': _buildNumberController.text, }; - final oldAppVersion = - prefs.getString('spoof_appversion') ?? _hardcodedVersion; - final newAppVersion = _appVersionController.text; - bool otherDataChanged = false; for (final key in oldValues.keys) { if (oldValues[key] != newValues[key]) { @@ -246,33 +239,7 @@ class _SpoofScreenState extends State { } } - final appVersionChanged = oldAppVersion != newAppVersion; - final isChangingAwayFromHardcoded = newAppVersion != _hardcodedVersion; - - if (appVersionChanged && isChangingAwayFromHardcoded) { - if (!mounted) return; - final l10n = AppLocalizations.of(context)!; - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(l10n.spoofDialogUnsureTitle), - content: Text(l10n.spoofDialogUnsureContent), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text(l10n.spoofDialogCancel), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text(l10n.spoofDialogYes), - ), - ], - ), - ); - if (confirmed != true) return; - } - - if (appVersionChanged && !otherDataChanged) { + if (!otherDataChanged) { await _saveAllData(prefs); if (mounted) Navigator.of(context).pop(); return; @@ -329,12 +296,7 @@ class _SpoofScreenState extends State { await prefs.setString('spoof_locale', _localeController.text); await prefs.setString('spoof_deviceid', _deviceIdController.text); await prefs.setString('spoof_devicetype', _selectedDeviceType); - await prefs.setString('spoof_appversion', _appVersionController.text); await prefs.setString('spoof_arch', _selectedArch); - await prefs.setInt( - 'spoof_buildnumber', - int.tryParse(_buildNumberController.text) ?? _hardcodedBuildNumber, - ); } void _generateNewDeviceId() { @@ -360,10 +322,7 @@ class _SpoofScreenState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Scaffold( - appBar: AppBar( - title: Text(l10n.spoofScreenTitle), - centerTitle: true, - ), + appBar: AppBar(title: Text(l10n.spoofScreenTitle), centerTitle: true), body: _isLoading ? const Center(child: CircularProgressIndicator()) : SingleChildScrollView( @@ -642,18 +601,21 @@ class _SpoofScreenState extends State { TextField( controller: _deviceIdController, decoration: - _inputDecoration(l10n.spoofFieldDeviceId, Icons.tag_outlined) - .copyWith( - suffixIcon: IconButton( - icon: const Icon(Icons.autorenew_outlined), - tooltip: l10n.spoofRegenerateIdTooltip, - onPressed: _generateNewDeviceId, - ), - ), + _inputDecoration( + l10n.spoofFieldDeviceId, + Icons.tag_outlined, + ).copyWith( + suffixIcon: IconButton( + icon: const Icon(Icons.autorenew_outlined), + tooltip: l10n.spoofRegenerateIdTooltip, + onPressed: _generateNewDeviceId, + ), + ), ), const SizedBox(height: 16), TextField( controller: _appVersionController, + enabled: false, decoration: _inputDecoration( l10n.spoofFieldAppVersion, Icons.info_outline_rounded, @@ -662,6 +624,7 @@ class _SpoofScreenState extends State { const SizedBox(height: 16), TextField( controller: _buildNumberController, + enabled: false, keyboardType: TextInputType.number, decoration: _inputDecoration( l10n.spoofFieldBuildNumber, @@ -693,8 +656,7 @@ class _SpoofScreenState extends State { _ChipOption('arm64', 'arm64', Icons.memory_outlined), ], selected: _selectedArch, - onSelected: (value) => - setState(() => _selectedArch = value), + onSelected: (value) => setState(() => _selectedArch = value), ), ], ), From 3c8621d5fefd819da641627b9318b546bd58da2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sun, 12 Apr 2026 14:18:26 +0700 Subject: [PATCH 077/323] =?UTF-8?q?=D0=BD=D0=B0=D1=85=D1=83=D0=B5=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=82=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 16 ++- lib/backend/modules/account.dart | 4 +- lib/core/protocol/packet.dart | 4 + .../screens/chats/chat_list_screen.dart | 20 +++- .../screens/profile/spoof_screen.dart | 113 +++++++++++------- lib/l10n/app_en.arb | 8 +- lib/l10n/app_localizations.dart | 38 +++++- lib/l10n/app_localizations_en.dart | 22 +++- lib/l10n/app_localizations_ru.dart | 22 +++- lib/l10n/app_ru.arb | 8 +- lib/main.dart | 20 ++-- 11 files changed, 213 insertions(+), 62 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 0ff3ace..2c50950 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -32,6 +32,7 @@ class Api { final _stateController = StreamController.broadcast(); final _sessionExpiredController = StreamController.broadcast(); + final _handshakeSuccessController = StreamController.broadcast(); Map? _userAgent; Map? get userAgent => _userAgent; @@ -44,6 +45,8 @@ class Api { Stream get stateStream => _stateController.stream; Stream get sessionExpiredStream => _sessionExpiredController.stream; + Stream get handshakeSuccessStream => + _handshakeSuccessController.stream; SessionState get state => _sessionState; StreamSubscription? _dataSubscription; @@ -93,6 +96,12 @@ class Api { _setSessionState(SessionState.online); _startPinging(); logger.i('Сессия онлайн, хэндшейк ок'); + _handshakeSuccessController.add( + response.payload['device_name'] as String? ?? 'Unknown', + ); + if (_onReconnectCallback != null) { + _onReconnectCallback!(); + } } else { logger.e('Хэндшейк отклонён: ${response.payload}'); } @@ -254,7 +263,8 @@ class Api { await for (final packet in _receiver.feed(data)) { if (packet.isError && packet.payload is Map && - packet.payload['message'] == 'FAIL_LOGIN_TOKEN') { + (packet.payload['message'] == 'FAIL_LOGIN_TOKEN' || + packet.payload['message'] == 'FAIL_WRONG_PASSWORD')) { _sessionExpiredController.add( SessionExpiredException(messageFromErrorPayload(packet.payload)), ); @@ -277,13 +287,11 @@ class Api { _socketStateSubscription = null; _receiver.reset(); _dispatcher.clearPending(); + _handshakeSuccessController.add('disconnected'); } Future reconnectAndLogin() async { await connect(); - if (_sessionState == SessionState.online && _onReconnectCallback != null) { - _onReconnectCallback!(); - } } void Function()? _onReconnectCallback; diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index d86108f..1f57233 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1022,7 +1022,9 @@ class AccountModule { void _checkPacketError(Packet packet, String method) { if (packet.isError) { final payload = packet.payload; - if (payload is Map && payload['message'] == 'FAIL_LOGIN_TOKEN') { + if (payload is Map && + (payload['message'] == 'FAIL_LOGIN_TOKEN' || + payload['message'] == 'FAIL_WRONG_PASSWORD')) { throw SessionExpiredException(messageFromErrorPayload(payload)); } throw PacketError(messageFromErrorPayload(payload)); diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 7a0a320..7b6fb38 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -65,6 +65,10 @@ class SessionExpiredException extends PacketError { String messageFromErrorPayload(dynamic payload) { if (payload is Map) { + final msg = payload['message']; + if (msg == 'FAIL_WRONG_PASSWORD' || msg == 'FAIL_LOGIN_TOKEN') { + return 'Ваш токен был отклонён сервером, хм... Попробуйте войти ещё раз.'; + } for (final key in ['localizedMessage', 'message', 'title']) { final v = payload[key]; if (v is String && v.trim().isNotEmpty) return v.trim(); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1f1bf57..2f2bf70 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -199,7 +199,20 @@ class _ChatListScreenState extends State Future _reloadChatsAndFolders() async { final p = await AppDatabase.loadActiveProfile(); - if (p != null) { + if (p == null) { + _syncFolderChatScrollControllersForCount(1); + if (mounted) { + setState(() { + _folders = []; + _selectedFolderId = null; + _foldersListKnown = null; + _isInitialLoading = false; + }); + } + return; + } + + try { final chats = await ChatsModule.getChats(p.id); var folders = await FoldersModule.loadFolders(p.id); final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); @@ -244,7 +257,7 @@ class _ChatListScreenState extends State _jumpFolderPageToSelection(); }); } - } else { + } catch (_) { _syncFolderChatScrollControllersForCount(1); if (mounted) { setState(() { @@ -253,6 +266,9 @@ class _ChatListScreenState extends State _foldersListKnown = null; _isInitialLoading = false; }); + } + } finally { + if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _jumpFolderPageToSelection(); diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index ff16a0c..1a1f724 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -9,8 +9,10 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../core/config/device_presets.dart'; import '../../../core/storage/spoofing_service.dart'; +import '../../../core/storage/token_storage.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../auth/login_screen.dart'; enum SpoofingMethod { partial, full } @@ -127,19 +129,10 @@ class _SpoofScreenState extends State { _deviceNameController.text = '${androidInfo.manufacturer} ${androidInfo.model}'; _osVersionController.text = 'Android ${androidInfo.version.release}'; - _selectedDeviceType = 'ANDROID'; _selectedArch = androidInfo.supportedAbis.isNotEmpty ? androidInfo.supportedAbis.first : 'arm64-v8a'; _buildNumberController.text = '$_hardcodedBuildNumber'; - } else if (Platform.isIOS) { - final iosInfo = await deviceInfo.iosInfo; - _deviceNameController.text = iosInfo.name; - _osVersionController.text = - '${iosInfo.systemName} ${iosInfo.systemVersion}'; - _selectedDeviceType = 'IOS'; - _selectedArch = 'arm64'; - _buildNumberController.text = '$_hardcodedBuildNumber'; } else { await _applyGeneratedData(); } @@ -168,15 +161,7 @@ class _SpoofScreenState extends State { _appVersionController.text = _hardcodedVersion; _deviceIdController.text = _generateDeviceId(); - _selectedDeviceType = preset.deviceType; - - if (preset.deviceType == 'ANDROID') { - _selectedArch = 'arm64-v8a'; - } else if (preset.deviceType == 'IOS') { - _selectedArch = 'arm64'; - } else { - _selectedArch = 'x86_64'; - } + _selectedArch = 'arm64-v8a'; _buildNumberController.text = '$_hardcodedBuildNumber'; if (_selectedMethod == SpoofingMethod.full) { @@ -247,32 +232,76 @@ class _SpoofScreenState extends State { if (!mounted) return; final l10n = AppLocalizations.of(context)!; - final confirmed = await showDialog( + final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.spoofDialogApplyTitle), - content: Text(l10n.spoofDialogApplyContent), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.spoofDialogApplyContent), + const SizedBox(height: 12), + Text( + l10n.spoofDialogApplyWarning, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w500, + ), + ), + ], + ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => Navigator.of(context).pop('cancel'), child: Text(l10n.spoofDialogApplyDeny), ), + TextButton( + onPressed: () => Navigator.of(context).pop('relogin'), + child: Text(l10n.spoofDialogReloginConfirm), + ), FilledButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => Navigator.of(context).pop('apply'), child: Text(l10n.spoofDialogApplyConfirm), ), ], ), ); - if (confirmed != true || !mounted) return; + if (!mounted || confirmed == null) return; + + if (confirmed == 'relogin') { + final prefs = await SharedPreferences.getInstance(); + await _saveAllData(prefs); + await api.disconnect(); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await TokenStorage.deleteToken(accountId); + } + await prefs.setBool('spoofing_enabled', true); + await api.connect(); + if (mounted) { + final navState = KometApp.navigatorKey.currentState; + if (navState != null) { + await navState.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (route) => false, + ); + } + } + return; + } + + if (confirmed != 'apply') return; await _saveAllData(prefs); try { await api.disconnect(); await api.connect(); - if (mounted) Navigator.of(context).pop(); + if (mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -452,29 +481,18 @@ class _SpoofScreenState extends State { text: l10n.spoofDeviceTypeDescription, ), const SizedBox(height: 12), - _buildChipSelector( - options: const [ - _ChipOption('ANDROID', 'ANDROID', Icons.android_outlined), - _ChipOption('IOS', 'iOS', Icons.phone_iphone_outlined), - _ChipOption( - 'DESKTOP', + Row( + children: [ + _buildDisabledChip('ANDROID', Icons.android_outlined, theme), + const SizedBox(width: 8), + _buildDisabledChip('iOS', Icons.phone_iphone_outlined, theme), + const SizedBox(width: 8), + _buildDisabledChip( 'Desktop', Icons.desktop_windows_outlined, + theme, ), ], - selected: _selectedDeviceType, - onSelected: (value) { - setState(() { - _selectedDeviceType = value; - if (value == 'ANDROID') { - _selectedArch = 'arm64-v8a'; - } else if (value == 'IOS') { - _selectedArch = 'arm64'; - } else { - _selectedArch = 'x86_64'; - } - }); - }, ), ], ), @@ -482,6 +500,15 @@ class _SpoofScreenState extends State { ); } + Widget _buildDisabledChip(String label, IconData icon, ThemeData theme) { + return Chip( + label: Text(label), + avatar: Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant), + backgroundColor: theme.colorScheme.surfaceContainerHighest, + side: BorderSide(color: theme.colorScheme.outlineVariant), + ); + } + Widget _buildDescriptionTile({ required IconData icon, required Color color, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c047aaa..806d0c5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -64,7 +64,7 @@ "spoofMethodPartialDescription": "Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.", "spoofMethodFullDescription": "All data including timezone and locale is generated randomly. Use this method at your own risk!", "spoofDeviceTypeTitle": "Device type", - "spoofDeviceTypeDescription": "Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.", + "spoofDeviceTypeDescription": "This field is not changeable, to avoid token association issues", "spoofDeviceTypeLabel": "Device type", "spoofMainSectionTitle": "Main data", "spoofFieldDeviceName": "Device name", @@ -88,6 +88,12 @@ "spoofDialogYes": "Yes", "spoofDialogApplyTitle": "Apply settings?", "spoofDialogApplyContent": "Need to reconnect the app, ok?", + "spoofDialogApplyWarning": "Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible", + "spoofDialogReloginTitle": "Done!", + "spoofDialogReloginContent": "Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.", + "spoofDialogReloginWarning": "Re-login now?", + "spoofDialogReloginDeny": "Later", + "spoofDialogReloginConfirm": "Re-login now", "spoofDialogApplyDeny": "No", "spoofDialogApplyConfirm": "Ok!", "spoofErrorApplyFailed": "Failed to apply settings: {error}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 15fbdb0..5bd5013 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -431,7 +431,7 @@ abstract class AppLocalizations { /// No description provided for @spoofDeviceTypeDescription. /// /// In en, this message translates to: - /// **'Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.'** + /// **'This field is not changeable, to avoid token association issues'** String get spoofDeviceTypeDescription; /// No description provided for @spoofDeviceTypeLabel. @@ -572,6 +572,42 @@ abstract class AppLocalizations { /// **'Need to reconnect the app, ok?'** String get spoofDialogApplyContent; + /// No description provided for @spoofDialogApplyWarning. + /// + /// In en, this message translates to: + /// **'Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible'** + String get spoofDialogApplyWarning; + + /// No description provided for @spoofDialogReloginTitle. + /// + /// In en, this message translates to: + /// **'Done!'** + String get spoofDialogReloginTitle; + + /// No description provided for @spoofDialogReloginContent. + /// + /// In en, this message translates to: + /// **'Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.'** + String get spoofDialogReloginContent; + + /// No description provided for @spoofDialogReloginWarning. + /// + /// In en, this message translates to: + /// **'Re-login now?'** + String get spoofDialogReloginWarning; + + /// No description provided for @spoofDialogReloginDeny. + /// + /// In en, this message translates to: + /// **'Later'** + String get spoofDialogReloginDeny; + + /// No description provided for @spoofDialogReloginConfirm. + /// + /// In en, this message translates to: + /// **'Re-login now'** + String get spoofDialogReloginConfirm; + /// No description provided for @spoofDialogApplyDeny. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4422b8f..bfce511 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -183,7 +183,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get spoofDeviceTypeDescription => - 'Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.'; + 'This field is not changeable, to avoid token association issues'; @override String get spoofDeviceTypeLabel => 'Device type'; @@ -256,6 +256,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get spoofDialogApplyContent => 'Need to reconnect the app, ok?'; + @override + String get spoofDialogApplyWarning => + 'Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible'; + + @override + String get spoofDialogReloginTitle => 'Done!'; + + @override + String get spoofDialogReloginContent => + 'Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.'; + + @override + String get spoofDialogReloginWarning => 'Re-login now?'; + + @override + String get spoofDialogReloginDeny => 'Later'; + + @override + String get spoofDialogReloginConfirm => 'Re-login now'; + @override String get spoofDialogApplyDeny => 'No'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 6648921..7a1c8b4 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -185,7 +185,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get spoofDeviceTypeDescription => - 'Выберите тип устройства для генерации пресетов. При нажатии \"Сгенерировать\" будут использоваться только пресеты выбранного типа.'; + 'Данное поле не изменяемое, во избежании проблем с ассоциацией токена'; @override String get spoofDeviceTypeLabel => 'Тип устройства'; @@ -258,6 +258,26 @@ class AppLocalizationsRu extends AppLocalizations { @override String get spoofDialogApplyContent => 'Нужно перезайти в приложение, ок?'; + @override + String get spoofDialogApplyWarning => + 'Ваш спуф изменится сразу. Но из-за особенностей МАХ, для того что-бы это стало заметно, вы должны перелогиниться в аккаунт'; + + @override + String get spoofDialogReloginTitle => 'Готово!'; + + @override + String get spoofDialogReloginContent => + 'Из-за особенности МАХ, ваш спуф изменён, но видны изменения будут только при перезаходе в аккаунт.'; + + @override + String get spoofDialogReloginWarning => 'Перезайти сейчас?'; + + @override + String get spoofDialogReloginDeny => 'Позже'; + + @override + String get spoofDialogReloginConfirm => 'Перелогиниться сейчас'; + @override String get spoofDialogApplyDeny => 'Не'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2efbabb..0f2378c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -64,7 +64,7 @@ "spoofMethodPartialDescription": "Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.", "spoofMethodFullDescription": "Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!", "spoofDeviceTypeTitle": "Тип устройства", - "spoofDeviceTypeDescription": "Выберите тип устройства для генерации пресетов. При нажатии \"Сгенерировать\" будут использоваться только пресеты выбранного типа.", + "spoofDeviceTypeDescription": "Данное поле не изменяемое, во избежании проблем с ассоциацией токена", "spoofDeviceTypeLabel": "Тип устройства", "spoofMainSectionTitle": "Основные данные", "spoofFieldDeviceName": "Имя устройства", @@ -88,6 +88,12 @@ "spoofDialogYes": "Да", "spoofDialogApplyTitle": "Применить настройки?", "spoofDialogApplyContent": "Нужно перезайти в приложение, ок?", + "spoofDialogApplyWarning": "Ваш спуф изменится сразу. Но из-за особенностей МАХ, для того что-бы это стало заметно, вы должны перелогиниться в аккаунт", + "spoofDialogReloginTitle": "Готово!", + "spoofDialogReloginContent": "Из-за особенности МАХ, ваш спуф изменён, но видны изменения будут только при перезаходе в аккаунт.", + "spoofDialogReloginWarning": "Перезайти сейчас?", + "spoofDialogReloginDeny": "Позже", + "spoofDialogReloginConfirm": "Перелогиниться сейчас", "spoofDialogApplyDeny": "Не", "spoofDialogApplyConfirm": "Ок!", "spoofErrorApplyFailed": "Ошибка при применении настроек: {error}", diff --git a/lib/main.dart b/lib/main.dart index 82ae9b6..0ce4998 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,6 +36,7 @@ void main() async { await AppDatabase.init(); await api.connect(); final initialLocale = await _loadInitialLocale(); + final prefs = await SharedPreferences.getInstance(); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; runApp( @@ -70,8 +71,9 @@ class KometAppState extends State { late Locale _locale; bool _isLoggingOut = false; - late final ValueNotifier fpsOverlayEnabled = - ValueNotifier(widget.initialFpsOverlay); + late final ValueNotifier fpsOverlayEnabled = ValueNotifier( + widget.initialFpsOverlay, + ); @override void initState() { @@ -79,10 +81,14 @@ class KometAppState extends State { _locale = widget.initialLocale; api.setReconnectCallback(() async { - final accountId = await TokenStorage.getActiveAccountId(); - if (accountId != null) { - await accountModule.login(accountId: accountId); - } + try { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null && + await TokenStorage.readToken(accountId) != null) { + final token = await TokenStorage.readToken(accountId); + await accountModule.login(accountId: accountId, token: token); + } + } catch (_) {} }); api.sessionExpiredStream.listen((SessionExpiredException e) async { @@ -250,7 +256,7 @@ class _StartupScreenState extends State<_StartupScreen> { Future _tryAutoLogin() async { final accountId = await TokenStorage.getActiveAccountId(); - if (accountId == null) { + if (accountId == null || await TokenStorage.readToken(accountId) == null) { _goToLogin(); return; } From 68df187735bf4768128623eea4d87ff300678f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Wed, 15 Apr 2026 22:30:10 +0700 Subject: [PATCH 078/323] =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D1=80=D1=83=D0=BD=D1=86?= =?UTF-8?q?=D1=8B,=20=D0=B3=D0=BE=D0=B2=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B4=D0=B0=20=D0=BD=D0=BE=20=D1=8D=D1=82=D0=BE=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D0=BE=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 1 + lib/frontend/widgets/message_bubble.dart | 402 +++++++++++++++++++- lib/models/attachment.dart | 29 ++ 3 files changed, 430 insertions(+), 2 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index a05c853..d89d068 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -203,6 +203,7 @@ class _ChatScreenState extends State originalText: a.originalText, originalChatId: a.originalChatId, originalAttachments: a.originalAttachments, + originalContact: a.originalContact, ); } return a; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 647da92..e6e2e45 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; import '../../models/attachment.dart'; @@ -80,11 +81,17 @@ class MessageBubble extends StatelessWidget { final first = message.attachments!.first; if (first is ForwardedMessageAttachment) { final fwd = first; + final hasContact = fwd.originalContact != null; final hasPhoto = fwd.originalAttachments != null && fwd.originalAttachments!.any((a) => a is PhotoAttachment); - return hasPhoto ? MessageType.attachment : MessageType.text; + final hasOther = + fwd.originalAttachments != null && + fwd.originalAttachments!.isNotEmpty; + if (hasContact || hasPhoto || hasOther) return MessageType.attachment; + return MessageType.text; } + if (first is ContactAttachment) return MessageType.attachment; if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; return MessageType.attachment; @@ -324,8 +331,16 @@ class MessageBubble extends StatelessWidget { ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final attachments = message.attachments; + final isForwardedContact = + attachments != null && + attachments.isNotEmpty && + attachments.first is ForwardedMessageAttachment && + (attachments.first as ForwardedMessageAttachment).originalContact != + null; + final forwarded = _getForwardedAttachment(); - final isForwarded = forwarded != null; + final isForwarded = forwarded != null && !isForwardedContact; return Row( mainAxisSize: MainAxisSize.min, @@ -498,15 +513,27 @@ class MessageBubble extends StatelessWidget { final first = attachments.first; if (first is ForwardedMessageAttachment) { final fwd = first; + if (fwd.originalContact != null) { + return _buildForwardedContactContent(context, fwd); + } final photos = fwd.originalAttachments ?.whereType() .toList(); if (photos != null && photos.isNotEmpty) { return _buildForwardedPhotoContent(context, fwd, photos); } + final files = fwd.originalAttachments; + if (files != null && files.isNotEmpty) { + return _buildForwardedGenericContent(context, fwd, files); + } return _buildTextContent(context); } + final contacts = attachments.whereType().toList(); + if (contacts.isNotEmpty) { + return _buildContactAttachment(context, contacts.first); + } + final photos = attachments.whereType().toList(); if (photos.isEmpty) { return _buildGenericAttachment(context, attachments.first); @@ -669,6 +696,136 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildForwardedFileContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List files, + ) { + final cs = Theme.of(context).colorScheme; + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ...files.map((file) => _buildFileAttachment(context, file)), + ], + ); + } + + Widget _buildForwardedGenericContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List attachments, + ) { + debugPrint('DEBUG: _buildForwardedGenericContent called'); + debugPrint('DEBUG: attachments count: ${attachments.length}'); + for (var i = 0; i < attachments.length; i++) { + debugPrint('DEBUG: attachment[$i] type: ${attachments[i].runtimeType}'); + debugPrint('DEBUG: attachment[$i] type field: ${attachments[i].type}'); + } + + final cs = Theme.of(context).colorScheme; + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ...attachments.map((a) { + if (a is FileAttachment) { + return _buildFileAttachment(context, a); + } + return const SizedBox.shrink(); + }), + ], + ); + } + Widget _buildSinglePhoto(BuildContext ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; final width = photo.width?.toDouble() ?? 200; @@ -1078,6 +1235,247 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildContactAttachment(BuildContext ctx, MessageAttachment contact) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final contactData = contact as ContactAttachment; + + final firstName = contactData.firstName ?? ''; + final lastName = contactData.lastName ?? ''; + final hasFirstName = firstName.isNotEmpty; + final hasLastName = lastName.isNotEmpty; + + final name = (hasFirstName || hasLastName) + ? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}' + .trim() + : (contactData.name ?? 'Contact'); + final photoUrl = contactData.photoUrl ?? contactData.baseUrl; + + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.65) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + final bgColor = isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA)); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(24), + ), + child: photoUrl != null && photoUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.network( + photoUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + ) + : Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name.isNotEmpty ? name : 'Contact', + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (contactData.phoneNumber != null) ...[ + const SizedBox(height: 2), + Text( + contactData.phoneNumber!, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildForwardedContactContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + ) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final contact = forwarded.originalContact!; + + final firstName = contact.firstName ?? ''; + final lastName = contact.lastName ?? ''; + final hasFirstName = firstName.isNotEmpty; + final hasLastName = lastName.isNotEmpty; + + final name = (hasFirstName || hasLastName) + ? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}' + .trim() + : (contact.name ?? 'Contact'); + final photoUrl = contact.photoUrl ?? contact.baseUrl; + + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.65) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + final bgColor = isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA)); + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(24), + ), + child: photoUrl != null && photoUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.network( + photoUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + ) + : Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name.isNotEmpty ? name : 'Contact', + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (contact.phoneNumber != null) ...[ + const SizedBox(height: 2), + Text( + contact.phoneNumber!, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ], + ); + } + void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) { // TODO: Open photo viewer } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 2c43222..2d3b6b1 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -325,6 +325,9 @@ class ContactAttachment extends MessageAttachment { final String? firstName; final String? lastName; final String? phoneNumber; + final String? photoUrl; + final int? contactId; + final String? name; const ContactAttachment({ super.previewData, @@ -334,6 +337,9 @@ class ContactAttachment extends MessageAttachment { this.firstName, this.lastName, this.phoneNumber, + this.photoUrl, + this.contactId, + this.name, }) : super(type: AttachmentType.contact); factory ContactAttachment.fromMap(Map map) { @@ -344,6 +350,9 @@ class ContactAttachment extends MessageAttachment { firstName: map['firstName'] as String?, lastName: map['lastName'] as String?, phoneNumber: map['phoneNumber'] as String?, + photoUrl: map['photoUrl'] as String?, + contactId: map['contactId'] as int?, + name: map['name'] as String?, ); } @@ -356,6 +365,9 @@ class ContactAttachment extends MessageAttachment { 'firstName': firstName, 'lastName': lastName, 'phoneNumber': phoneNumber, + 'photoUrl': photoUrl, + 'contactId': contactId, + 'name': name, }; } @@ -442,6 +454,7 @@ class ForwardedMessageAttachment extends MessageAttachment { final String? originalText; final int? originalChatId; final List? originalAttachments; + final ContactAttachment? originalContact; const ForwardedMessageAttachment({ required this.originalSenderId, @@ -452,6 +465,7 @@ class ForwardedMessageAttachment extends MessageAttachment { this.originalText, this.originalChatId, this.originalAttachments, + this.originalContact, }) : super(type: AttachmentType.photo); factory ForwardedMessageAttachment.fromMap(Map map) { @@ -470,11 +484,25 @@ class ForwardedMessageAttachment extends MessageAttachment { } List? originalAttaches; + ContactAttachment? originalContact; if (message != null) { final attaches = message['attaches'] as List?; if (attaches != null) { + final contactAttaches = attaches + .whereType() + .where((a) => (a['_type'] as String?)?.toUpperCase() == 'CONTACT') + .toList(); + if (contactAttaches.isNotEmpty) { + originalContact = ContactAttachment.fromMap( + Map.from(contactAttaches.first), + ); + } originalAttaches = attaches .whereType() + .where((a) { + final type = (a['_type'] as String?)?.toUpperCase(); + return type != 'CONTACT'; + }) .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); } @@ -487,6 +515,7 @@ class ForwardedMessageAttachment extends MessageAttachment { originalText: message?['text'] as String?, originalChatId: link?['chatId'] as int?, originalAttachments: originalAttaches, + originalContact: originalContact, ); } From 4b8829fddfee3768572bdccedbc9541f667caaad Mon Sep 17 00:00:00 2001 From: InviseDivine Date: Wed, 15 Apr 2026 17:46:40 +0200 Subject: [PATCH 079/323] feat: users in chats list, avatars and nicknames, users count in chats --- lib/backend/modules/chats.dart | 249 ++++++++++-------- lib/core/storage/app_database.dart | 41 ++- .../screens/chats/chat_list_screen.dart | 114 +++++--- lib/frontend/screens/chats/chat_screen.dart | 16 +- lib/frontend/widgets/message_bubble.dart | 128 ++++++--- 5 files changed, 363 insertions(+), 185 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index f8c81d6..3b0fb27 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; class CachedChat { final int id; @@ -17,6 +20,7 @@ class CachedChat { final int dontDisturbUntil; final bool isOnline; final int seenTime; + final Map participants; const CachedChat({ required this.id, @@ -35,6 +39,7 @@ class CachedChat { required this.dontDisturbUntil, required this.isOnline, required this.seenTime, + required this.participants, }); factory CachedChat.fromDbRow(Map row) => CachedChat( @@ -54,6 +59,8 @@ class CachedChat { dontDisturbUntil: row['dont_disturb_until'] as int, isOnline: (row['is_online'] as int) == 1, seenTime: row['seen_time'] as int, + // watafuc + participants: Map.from(jsonDecode(row['participants'])).map((k, v) => MapEntry(int.parse(k), v)) ); Map toDbRow() => { @@ -73,6 +80,7 @@ class CachedChat { 'dont_disturb_until': dontDisturbUntil, 'is_online': isOnline ? 1 : 0, 'seen_time': seenTime, + 'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))) }; } @@ -87,51 +95,72 @@ class ChatsModule { int accountId, int currentUserId, ) async { - final chats = data['chats']; - if (chats is! List || chats.isEmpty) return; + try { + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return; - final contactsMap = _buildContactsMap(data['contacts']); - // Config contains mute setup and fav indexes: config -> chats -> id - final configMap = data['config'] is Map ? data['config'] as Map : {}; - final chatsConfig = configMap['chats'] is Map - ? configMap['chats'] as Map - : {}; - // Presence for online statuses - final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; - final cachedAt = DateTime.now().millisecondsSinceEpoch; + final contactsMap = _buildContactsMap(data['contacts']); + // Config contains mute setup and fav indexes: config -> chats -> id + final configMap = data['config'] is Map ? data['config'] as Map : {}; + final chatsConfig = configMap['chats'] is Map + ? configMap['chats'] as Map + : {}; + // Presence for online statuses + final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; + final cachedAt = DateTime.now().millisecondsSinceEpoch; - final existingRows = await AppDatabase.loadChats(accountId); - final existing = { - for (final row in existingRows) - row['id'] as int: CachedChat.fromDbRow(row), - }; + final existingRows = await AppDatabase.loadChats(accountId); + final existing = { + for (final row in existingRows) + row['id'] as int: CachedChat.fromDbRow(row), + }; - final rows = chats - .whereType() - .map( - (c) => _parseChat( - c.cast(), - accountId, - currentUserId, - contactsMap, - chatsConfig, - presenceMap, - existing, - cachedAt, - ), - ) - .whereType() - .map((c) => c.toDbRow()) - .toList(); + final rows = chats + .whereType() + .map( + (c) => _parseChat( + c.cast(), + accountId, + currentUserId, + contactsMap, + chatsConfig, + presenceMap, + existing, + cachedAt, + ), + ) + .whereType() + .map((c) => c.toDbRow()) + .toList(); - if (rows.isNotEmpty) { - await AppDatabase.saveChats(rows); + if (rows.isNotEmpty) { + await AppDatabase.saveChats(rows); + } + } catch (e) { + logger.e("Ошибка при синке: $e"); } } static Future> getChats(int accountId) async { - final rows = await AppDatabase.loadChats(accountId); - return rows.map(CachedChat.fromDbRow).toList(); + try { + final rows = await AppDatabase.loadChats(accountId); + + return rows.map(CachedChat.fromDbRow).toList(); + } catch (e) { + logger.e("Ошибка при получении чатов: $e"); + return []; + } + } + static Future> getChat(int accountId, int chatId) async { + try { + final rows = await AppDatabase.loadChat(accountId, chatId); + + return rows.map(CachedChat.fromDbRow).toList(); + } catch (e) { + logger.e("Ошибка при получении чата: $e"); + + return []; + } } static Future clearCache(int accountId) => @@ -157,80 +186,88 @@ class ChatsModule { Map existing, int cachedAt, ) { - final id = chat['id']; - if (id is! int) return null; + try { + final id = chat['id']; + if (id is! int) return null; - final type = (chat['type'] as String?) ?? 'DIALOG'; - int? otherId; + final type = (chat['type'] as String?) ?? 'DIALOG'; + int? otherId; - String? title; - String? iconUrl; + String? title; + String? iconUrl; - if (type == 'DIALOG') { - otherId = _otherParticipantId(chat['participants'], currentUserId); - final contact = otherId != null ? contactsMap[otherId] : null; + if (type == 'DIALOG') { + otherId = _otherParticipantId(chat['participants'], currentUserId); + final contact = otherId != null ? contactsMap[otherId] : null; - if (contact != null) { - title = _nameFromContact(contact); - iconUrl = contact['baseUrl'] as String?; - } else { - title = existing[id]?.title; - iconUrl = existing[id]?.iconUrl; - } - } else { - title = chat['title'] as String?; - iconUrl = chat['baseIconUrl'] as String?; + if (contact != null) { + title = _nameFromContact(contact); + iconUrl = contact['baseUrl'] as String?; + } else { + title = existing[id]?.title; + iconUrl = existing[id]?.iconUrl; + } + } else { + title = chat['title'] as String?; + iconUrl = chat['baseIconUrl'] as String?; + } + + final lastMsg = chat['lastMessage']; + int? lastMsgId; + int? lastMsgTime; + String? lastMsgText; + int? lastMsgSenderId; + + if (lastMsg is Map) { + lastMsgId = lastMsg['id'] as int?; + lastMsgTime = lastMsg['time'] as int?; + lastMsgText = lastMsg['text'] as String?; + lastMsgSenderId = lastMsg['sender'] as int?; + } + + final config = chatsConfig[id.toString()] ?? chatsConfig[id]; + int? favIndex; + int dontDisturbUntil = 0; + if (config is Map) { + favIndex = config['favIndex'] as int?; + dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; + } + + int seenTime = 0; + bool isOnline = false; + if (type == 'DIALOG' && otherId != null) { + final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; + if (presence is Map) { + seenTime = (presence['seen'] as int?) ?? 0; + isOnline = (presence['status'] as int?) == 1; + } + } + Map participants = Map.from(chat['participants']); + + return CachedChat( + id: id, + accountId: accountId, + type: type, + title: title, + iconUrl: iconUrl, + lastMsgId: lastMsgId, + lastMsgTime: lastMsgTime, + lastMsgText: lastMsgText, + lastMsgSenderId: lastMsgSenderId, + unreadCount: (chat['newMessages'] as int?) ?? 0, + lastEventTime: (chat['lastEventTime'] as int?) ?? 0, + cachedAt: cachedAt, + favIndex: favIndex, + dontDisturbUntil: dontDisturbUntil, + isOnline: isOnline, + seenTime: seenTime, + participants: participants + ); + } catch (e) { + logger.e("Ошибка при парсинге чата: $e"); + + return null; } - - final lastMsg = chat['lastMessage']; - int? lastMsgId; - int? lastMsgTime; - String? lastMsgText; - int? lastMsgSenderId; - - if (lastMsg is Map) { - lastMsgId = lastMsg['id'] as int?; - lastMsgTime = lastMsg['time'] as int?; - lastMsgText = lastMsg['text'] as String?; - lastMsgSenderId = lastMsg['sender'] as int?; - } - - final config = chatsConfig[id.toString()] ?? chatsConfig[id]; - int? favIndex; - int dontDisturbUntil = 0; - if (config is Map) { - favIndex = config['favIndex'] as int?; - dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; - } - - int seenTime = 0; - bool isOnline = false; - if (type == 'DIALOG' && otherId != null) { - final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; - if (presence is Map) { - seenTime = (presence['seen'] as int?) ?? 0; - isOnline = (presence['status'] as int?) == 1; - } - } - - return CachedChat( - id: id, - accountId: accountId, - type: type, - title: title, - iconUrl: iconUrl, - lastMsgId: lastMsgId, - lastMsgTime: lastMsgTime, - lastMsgText: lastMsgText, - lastMsgSenderId: lastMsgSenderId, - unreadCount: (chat['newMessages'] as int?) ?? 0, - lastEventTime: (chat['lastEventTime'] as int?) ?? 0, - cachedAt: cachedAt, - favIndex: favIndex, - dontDisturbUntil: dontDisturbUntil, - isOnline: isOnline, - seenTime: seenTime, - ); } static int? _otherParticipantId(dynamic participants, int currentUserId) { diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index f9bb88e..5890f21 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:komet/core/utils/logger.dart'; import 'package:path/path.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -138,7 +139,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 7, + version: 8, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -167,6 +168,11 @@ class AppDatabase { 'ALTER TABLE profile ADD COLUMN profile_options TEXT', ); } + if (oldVersion < 8) { + await db.execute( + 'ALTER TABLE chats_cache ADD COLUMN participants TEXT', + ); + } }, ); } @@ -235,6 +241,7 @@ class AppDatabase { dont_disturb_until INTEGER NOT NULL DEFAULT 0, is_online INTEGER NOT NULL DEFAULT 0, seen_time INTEGER NOT NULL DEFAULT 0, + participants TEXT NOT NULL DEFAULT "", PRIMARY KEY (id, account_id) ) '''; @@ -375,18 +382,32 @@ class AppDatabase { // Chats cache static Future saveChats(List> rows) async { - final db = await _instance; - final batch = db.batch(); - for (final row in rows) { - batch.insert( - 'chats_cache', - row, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + try { + final db = await _instance; + final batch = db.batch(); + for (final row in rows) { + batch.insert( + 'chats_cache', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } catch (e) { + logger.e("Ошибка при сохранении чата: $e"); } - await batch.commit(noResult: true); } + static Future>> loadChat(int accountId, int chatId) async { + final db = await _instance; + return db.query( + 'chats_cache', + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, chatId], + orderBy: 'last_event_time DESC', + ); + } + static Future>> loadChats(int accountId) async { final db = await _instance; return db.query( diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 2f2bf70..986cbf2 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:komet/backend/modules/messages.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'dart:math'; import 'dart:ui' as ui; @@ -15,7 +16,7 @@ import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; -import '../../../main.dart' show accountModule, api; +import '../../../main.dart' show accountModule, api, messagesModule; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -60,40 +61,49 @@ class ChatListScreen extends StatefulWidget { class _ChatListScreenState extends State with TickerProviderStateMixin { String? _selectedFolderId; + List _folders = []; + int _currentNavIndex = 0; - bool _navDragging = false; - double _navDragDx = 0; - double _navDragBaseLeft = 0; - late AnimationController _navPageAnimController; + double _navPageAnimStart = 0; double _navPageAnimEnd = 0; - bool _isFabOpen = false; - bool _showCacheWarning = false; - late AnimationController _fabController; - final Set _selectedChats = {}; - late PageController _folderPageController; - final List _folderChatScrollControllers = []; - final List _folderChatScrollListenerFns = []; - double _pullRatio = 0.0; - static const double _kStoriesPullTriggerPx = 16.0; - late AnimationController _storiesRevealController; + double _navDragDx = 0; + double _navDragBaseLeft = 0; double _revealAnimBegin = 0.0; double _closeAnimBegin = 0.0; + double _pullRatio = 0.0; + static const double _kStoriesPullTriggerPx = 16.0; + + bool _navDragging = false; + bool _isFabOpen = false; + bool _showCacheWarning = false; bool _storiesAnimClosing = false; bool _storiesDockedOpen = false; bool _storiesOverscrollRevealArmed = true; + bool _shouldCollapseSearch = false; + bool get _isSelectionMode => _selectedChats.isNotEmpty; + bool? _foldersListKnown; + + late AnimationController _navPageAnimController; + late AnimationController _fabController; + late PageController _folderPageController; + late AnimationController _storiesRevealController; + + final List _folderChatScrollControllers = []; + final List _folderChatScrollListenerFns = []; + final Set _selectedChats = {}; + DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); ProfileData? _profile; + List _chats = []; + SessionState _sessionState = SessionState.disconnected; + StreamSubscription? _stateSub; StreamSubscription? _loginSub; - bool? _foldersListKnown; - bool _shouldCollapseSearch = false; - - bool get _isSelectionMode => _selectedChats.isNotEmpty; void _toggleSelection(String chatId) { setState(() { @@ -1015,18 +1025,60 @@ class _ChatListScreenState extends State return _buildChatShimmer(); } final chat = chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); + + if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { + final secondId = chat.participants.entries.where((entry) => entry.key != _profile?.id).first.key; + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(secondId); + final name = ContactCache.get(secondId); + final avatar = ContactCache.getAvatar(secondId); + + return _buildChatItem( + chat.id.toString(), + name ?? "Пользователь", + chat.lastMsgText?.replaceAll('\n', ' ') ?? '', + _formatTime(chat.lastMsgTime), + avatar ?? "", + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + } else { + if (chat.lastMsgSenderId != null ) { + final ss = messagesModule.searchContactById(chat.lastMsgSenderId!); + } + + final name = chat.lastMsgSenderId != null + ? ContactCache.get(chat.lastMsgSenderId!) + : null; + + final avatar = chat.lastMsgSenderId != null + ? ContactCache.getAvatar(chat.lastMsgSenderId!) + : null; + + String fullMsg = ""; + + if (name?.isNotEmpty == true && chat.id != 0) { + fullMsg += "$name: "; + } + + if (chat.lastMsgText?.isNotEmpty == true) { + fullMsg += chat.lastMsgText ?? ""; + } + + return _buildChatItem( + chat.id.toString(), + chat.id == 0 ? "Избранное" : chat.title ?? "Чат", + fullMsg, + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + } }, childCount: _isInitialLoading ? 10 : chats.length), ), SliverPadding( diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index a05c853..40a0359 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:komet/backend/modules/chats.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; @@ -35,7 +36,8 @@ class _ChatScreenState extends State late AnimationController _shimmerController; List _messages = []; int _myId = 0; - + CachedChat? chat; + @override void initState() { super.initState(); @@ -51,6 +53,11 @@ class _ChatScreenState extends State Future _loadHistory() async { final activeProfile = await AppDatabase.loadActiveProfile(); _myId = activeProfile?.id ?? 0; + ChatsModule.getChat(_myId, widget.chatId).then((value) { + chat = value[0]; + }).catchError((error) { + + }); final cachedRows = await AppDatabase.loadMessages( _myId, @@ -240,6 +247,10 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + + // TODO: Локализация + // TODO: Cклонения + String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently"; return Scaffold( backgroundColor: cs.surface, appBar: AppBar( @@ -284,7 +295,7 @@ class _ChatScreenState extends State ), ), Text( - 'last seen recently', + status ?? "", style: TextStyle( color: cs.onSurfaceVariant, fontSize: 12, @@ -353,6 +364,7 @@ class _ChatScreenState extends State myId: _myId, prevMessage: prevMessage, nextMessage: nextMessage, + chatType: chat!.type, ); }, ); diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 647da92..bbc93f9 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; import '../../models/attachment.dart'; @@ -37,6 +40,7 @@ class MessageBubble extends StatelessWidget { final int myId; final CachedMessage? prevMessage; final CachedMessage? nextMessage; + final String chatType; const MessageBubble({ super.key, @@ -45,6 +49,7 @@ class MessageBubble extends StatelessWidget { required this.myId, this.prevMessage, this.nextMessage, + required this.chatType }); bool get isGroupedWithNext { @@ -277,6 +282,11 @@ class MessageBubble extends StatelessWidget { final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(message.senderId); + String? senderAvatar = ContactCache.getAvatar(message.senderId); + String? displaySender = ContactCache.get(message.senderId); + return Padding( padding: EdgeInsets.only( left: isMe ? 60 : 12, @@ -285,22 +295,52 @@ class MessageBubble extends StatelessWidget { bottom: bottomMargin, ), child: Align( - alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, - ), - decoration: BoxDecoration( - color: isMe - ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) - : (isDark - ? cs.surfaceContainerHighest - : const Color(0xFFE9E9EB)), - borderRadius: _borderRadius, - ), - padding: padding, - child: _buildContent(context), - ), + child: Row( + mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + spacing: 8.0, + children: [ + if (senderAvatar != null && senderAvatar.isNotEmpty && !isMe && chatType != "DIALOG" + && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + CircleAvatar( + radius: 15, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else if (displaySender != null && !isMe && chatType != "DIALOG" + && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + CircleAvatar( + radius: 15, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender!.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ) + // Заглушка для паддинга + else + CircleAvatar( + radius: 15, + backgroundColor: Color(0x00000000) + ), + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE9E9EB)), + borderRadius: _borderRadius, + ), + padding: padding, + child: _buildContent(context), + ), + ], + ) ), ); } @@ -327,30 +367,46 @@ class MessageBubble extends StatelessWidget { final forwarded = _getForwardedAttachment(); final isForwarded = forwarded != null; - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(message.senderId); + String? displaySender = ContactCache.get(message.senderId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible( - child: isForwarded - ? _buildForwardedInlineText(context, forwarded, textColor) - : Text( - message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), - ), + if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId) + Text( + displaySender ?? "", + textAlign: TextAlign.left, + // TODO: Получение цветов по хешу ника + style: TextStyle(color: cs.onPrimaryContainer) ), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - _formatTime(message.time), - style: TextStyle( - color: textColor.withValues(alpha: 0.7), - fontSize: 10, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: isForwarded + ? _buildForwardedInlineText(context, forwarded, textColor) + : Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), ), - ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + _formatTime(message.time), + style: TextStyle( + color: textColor.withValues(alpha: 0.7), + fontSize: 10, + ), + ), + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + ], ), - if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], ], ); } From d359e7147050255864aad719b5b1ca6e6d9a968f Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 19 Apr 2026 12:30:44 +0700 Subject: [PATCH 080/323] =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D0=BD=D0=BE=20=D0=B7=D0=B0=20=D0=B8=D0=BD=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 22 ++++--- lib/frontend/screens/chats/chat_screen.dart | 39 ++++++++++++ lib/frontend/widgets/message_bubble.dart | 59 +++++++++---------- 3 files changed, 81 insertions(+), 39 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 986cbf2..d00b667 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1042,6 +1042,7 @@ class _ChatListScreenState extends State isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, + chatType: "DIALOG", ); } else { if (chat.lastMsgSenderId != null ) { @@ -1077,6 +1078,7 @@ class _ChatListScreenState extends State isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, + chatType: chat.type, ); } }, childCount: _isInitialLoading ? 10 : chats.length), @@ -1679,6 +1681,7 @@ class _ChatListScreenState extends State bool isRead = false, int unreadCount = 0, bool isMuted = false, + String chatType = "CHAT", }) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedChats.contains(id); @@ -1688,16 +1691,17 @@ class _ChatListScreenState extends State if (_isSelectionMode) { _toggleSelection(id); } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatScreen( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, +Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, + ), ), - ), - ); + ); } }, onLongPress: () => _toggleSelection(id), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 28fb06d..f127ce2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -14,12 +14,14 @@ class ChatScreen extends StatefulWidget { final int chatId; final String name; final String imageUrl; + final String chatType; const ChatScreen({ super.key, required this.chatId, required this.name, required this.imageUrl, + required this.chatType, }); @override @@ -469,6 +471,43 @@ class _ChatScreenState extends State Widget _buildInputArea(BuildContext context) { final cs = Theme.of(context).colorScheme; final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85); + + if (widget.chatType == "CHANNEL") { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: GestureDetector( + onTap: () {}, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), + borderRadius: BorderRadius.circular(28), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Center( + child: Text( + 'Отключить уведомления', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } + return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 74c4ed9..819b7cc 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -296,41 +296,41 @@ class MessageBubble extends StatelessWidget { return Padding( padding: EdgeInsets.only( - left: isMe ? 60 : 12, - right: isMe ? 12 : 60, + left: isMe ? 12 : 12, + right: isMe ? 12 : 12, top: topMargin, bottom: bottomMargin, ), child: Align( child: Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, - spacing: 8.0, + crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (senderAvatar != null && senderAvatar.isNotEmpty && !isMe && chatType != "DIALOG" - && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) - CircleAvatar( - radius: 15, - backgroundImage: NetworkImage(senderAvatar), - backgroundColor: cs.primaryContainer, - ) - else if (displaySender != null && !isMe && chatType != "DIALOG" - && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) - CircleAvatar( - radius: 15, - backgroundColor: cs.primaryContainer, - child: Text( - displaySender!.isNotEmpty - ? displaySender[0].toUpperCase() - : '?', - style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), - ), - ) - // Заглушка для паддинга - else - CircleAvatar( - radius: 15, - backgroundColor: Color(0x00000000) - ), + if (!isMe && chatType == "CHAT" && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + ...(senderAvatar != null && senderAvatar.isNotEmpty) + ? [ + CircleAvatar( + radius: 15, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + ] + : [ + CircleAvatar( + radius: 15, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender != null && displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ) + ] + else if (!isMe && chatType != "CHAT") + SizedBox(width: 0) + else if (!isMe) + CircleAvatar(radius: 15, backgroundColor: Color(0x00000000)), Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.75, @@ -389,11 +389,10 @@ class MessageBubble extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId) + if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId && chatType == "CHAT") Text( displaySender ?? "", textAlign: TextAlign.left, - // TODO: Получение цветов по хешу ника style: TextStyle(color: cs.onPrimaryContainer) ), Row( From 28ff49ca526c600dd8b57f29298280950769634e Mon Sep 17 00:00:00 2001 From: Jganenok Date: Tue, 21 Apr 2026 17:31:36 +0700 Subject: [PATCH 081/323] =?UTF-8?q?=D1=85=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 31 ++++++++++++------- .../screens/chats/chat_list_screen.dart | 6 ---- lib/frontend/widgets/message_bubble.dart | 4 --- lib/models/attachment.dart | 28 ++++++++--------- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 00dd1c0..1526cac 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -75,13 +75,13 @@ class CachedMessage { } return CachedMessage( - id: row['id'] as String, - accountId: row['account_id'] as int, - chatId: row['chat_id'] as int, - senderId: row['sender_id'] as int, - text: row['text'] as String?, - time: row['time'] as int, - status: row['status'] as String?, + id: row['id']?.toString() ?? '', + accountId: row['account_id'] is int ? row['account_id'] as int : int.tryParse(row['account_id']?.toString() ?? '') ?? 0, + chatId: row['chat_id'] is int ? row['chat_id'] as int : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0, + senderId: row['sender_id'] is int ? row['sender_id'] as int : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0, + text: row['text']?.toString(), + time: row['time'] is int ? row['time'] as int : int.tryParse(row['time']?.toString() ?? '') ?? 0, + status: row['status']?.toString(), payload: payload, attachments: attachments, ); @@ -209,15 +209,22 @@ class MessagesModule { id: id, accountId: accountId, chatId: chatId, - senderId: (m['sender'] as int?) ?? 0, - text: m['text'] as String?, - time: (m['time'] as int?) ?? 0, - status: m['status'] as String?, + senderId: _parseIntField(m['sender']), + text: m['text']?.toString(), + time: _parseIntField(m['time']), + status: m['status']?.toString(), payload: Map.from(m.cast()), attachments: attachments, ); } + int _parseIntField(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) return int.tryParse(value) ?? 0; + return int.tryParse(value.toString()) ?? 0; + } + Future sendMessage( int accountId, int chatId, @@ -356,6 +363,8 @@ class MessagesModule { final cached = ContactCache.get(contactId); if (cached != null) return cached; + if (_api.state != SessionState.online) return null; + try { final response = await _api.sendRequest(Opcode.contactInfo, { 'contactIds': [contactId], diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index d00b667..9a9878b 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1028,8 +1028,6 @@ class _ChatListScreenState extends State if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { final secondId = chat.participants.entries.where((entry) => entry.key != _profile?.id).first.key; - // TODO: Нормальное кеширование контактов - final ss = messagesModule.searchContactById(secondId); final name = ContactCache.get(secondId); final avatar = ContactCache.getAvatar(secondId); @@ -1045,10 +1043,6 @@ class _ChatListScreenState extends State chatType: "DIALOG", ); } else { - if (chat.lastMsgSenderId != null ) { - final ss = messagesModule.searchContactById(chat.lastMsgSenderId!); - } - final name = chat.lastMsgSenderId != null ? ContactCache.get(chat.lastMsgSenderId!) : null; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 819b7cc..1fc12c2 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -289,8 +289,6 @@ class MessageBubble extends StatelessWidget { final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; - // TODO: Нормальное кеширование контактов - final ss = messagesModule.searchContactById(message.senderId); String? senderAvatar = ContactCache.getAvatar(message.senderId); String? displaySender = ContactCache.get(message.senderId); @@ -382,8 +380,6 @@ class MessageBubble extends StatelessWidget { final forwarded = _getForwardedAttachment(); final isForwarded = forwarded != null && !isForwardedContact; - // TODO: Нормальное кеширование контактов - final ss = messagesModule.searchContactById(message.senderId); String? displaySender = ContactCache.get(message.senderId); return Column( diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 2d3b6b1..25ce0a6 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -344,15 +344,15 @@ class ContactAttachment extends MessageAttachment { factory ContactAttachment.fromMap(Map map) { return ContactAttachment( - previewData: map['previewData'] as String?, - baseUrl: map['baseUrl'] as String?, - userId: map['userId'] as String?, - firstName: map['firstName'] as String?, - lastName: map['lastName'] as String?, - phoneNumber: map['phoneNumber'] as String?, - photoUrl: map['photoUrl'] as String?, - contactId: map['contactId'] as int?, - name: map['name'] as String?, + previewData: map['previewData']?.toString(), + baseUrl: map['baseUrl']?.toString(), + userId: map['userId']?.toString(), + firstName: map['firstName']?.toString(), + lastName: map['lastName']?.toString(), + phoneNumber: map['phoneNumber']?.toString(), + photoUrl: map['photoUrl']?.toString(), + contactId: map['contactId'] is int ? map['contactId'] as int : int.tryParse(map['contactId']?.toString() ?? ''), + name: map['name']?.toString(), ); } @@ -426,11 +426,11 @@ class ControlAttachment extends MessageAttachment { factory ControlAttachment.fromMap(Map map) { return ControlAttachment( - previewData: map['previewData'] as String?, - baseUrl: map['baseUrl'] as String?, - event: map['event'] as String?, - title: map['title'] as String?, - userIds: (map['userIds'] as List?)?.cast(), + previewData: map['previewData']?.toString(), + baseUrl: map['baseUrl']?.toString(), + event: map['event']?.toString(), + title: map['title']?.toString(), + userIds: (map['userIds'] as List?)?.map((e) => e is int ? e : int.tryParse(e?.toString() ?? '') ?? 0).toList(), ); } From bf49c6639d88d54e7ec8e13a7fe70990f9414ed7 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 21 Apr 2026 18:59:35 +0300 Subject: [PATCH 082/323] =?UTF-8?q?fix:=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=B0=D1=82=D1=83=D1=80=D1=8B=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/code_confirmation_screen.dart | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 3840751..512a938 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -34,13 +34,14 @@ class _CodeConfirmationScreenState extends State late AnimationController _shakeController; late Animation _shakeAnimation; + bool _keyboardScheduled = false; + Animation? _routeAnimation; + AnimationStatusListener? _routeAnimationListener; + @override void initState() { super.initState(); _startTimer(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); _shakeController = AnimationController( vsync: this, @@ -56,8 +57,17 @@ class _CodeConfirmationScreenState extends State ]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear)); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scheduleKeyboardOpen(); + } + @override void dispose() { + if (_routeAnimationListener != null) { + _routeAnimation?.removeStatusListener(_routeAnimationListener!); + } _timer?.cancel(); _errorTimer?.cancel(); _shakeController.dispose(); @@ -66,6 +76,36 @@ class _CodeConfirmationScreenState extends State super.dispose(); } + void _scheduleKeyboardOpen() { + if (_keyboardScheduled) return; + _keyboardScheduled = true; + + final animation = ModalRoute.of(context)?.animation; + if (animation == null || animation.status == AnimationStatus.completed) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _openKeyboard(); + }); + return; + } + + _routeAnimation = animation; + _routeAnimationListener = (status) { + if (status == AnimationStatus.completed) { + animation.removeStatusListener(_routeAnimationListener!); + _routeAnimationListener = null; + if (mounted) _openKeyboard(); + } + }; + animation.addStatusListener(_routeAnimationListener!); + } + + void _openKeyboard() { + if (!_focusNode.hasFocus) { + _focusNode.requestFocus(); + } + SystemChannels.textInput.invokeMethod('TextInput.show'); + } + void _startTimer() { _timer?.cancel(); _timerSeconds = 30; @@ -215,7 +255,7 @@ class _CodeConfirmationScreenState extends State ), ), GestureDetector( - onTap: () => _focusNode.requestFocus(), + onTap: _openKeyboard, child: FittedBox( child: Row( children: List.generate(6, (index) { From ff93286021aac143c21297e7036ce563397985d6 Mon Sep 17 00:00:00 2001 From: InviseDivine Date: Tue, 28 Apr 2026 19:11:43 +0200 Subject: [PATCH 083/323] chat info not finished --- .../screens/chats/chat_info_screen.dart | 61 ++++++++ lib/frontend/screens/chats/chat_screen.dart | 140 ++++++++++-------- lib/frontend/widgets/message_bubble.dart | 114 +++++++------- 3 files changed, 198 insertions(+), 117 deletions(-) create mode 100644 lib/frontend/screens/chats/chat_info_screen.dart diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart new file mode 100644 index 0000000..c50ae93 --- /dev/null +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class ChatInfoScreen extends StatefulWidget { + final int chatId; + final String name; + final String imageUrl; + final String chatType; + + const ChatInfoScreen({ + super.key, + required this.chatId, + required this.name, + required this.imageUrl, + required this.chatType, + }); + @override + State createState() => _ChatInfoScreenState(); +} +class _ChatInfoScreenState extends State{ + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + + ), + body: + SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (widget.imageUrl.isNotEmpty) + CircleAvatar( + radius: 36, + backgroundImage: NetworkImage(widget.imageUrl), + ) + else + CircleAvatar( + radius: 36, + backgroundColor: cs.primaryContainer, + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), + ), + ), + Text( + widget.name, + style: TextStyle(color: Colors.white, fontSize: 24, height: 1.3), + ), + + ], + ), + ), + ); + } +} + diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index f127ce2..41069ac 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; @@ -256,71 +257,84 @@ class _ChatScreenState extends State String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently"; return Scaffold( backgroundColor: cs.surface, - appBar: AppBar( - backgroundColor: cs.surfaceContainerHigh, - foregroundColor: cs.onSurface, - elevation: 0, - surfaceTintColor: Colors.transparent, - iconTheme: IconThemeData(color: cs.onSurface), - leading: IconButton( - icon: const Icon(Symbols.arrow_back, weight: 400), - onPressed: () => Navigator.pop(context), - ), - titleSpacing: 0, - title: Row( - children: [ - if (widget.imageUrl.isNotEmpty) - CircleAvatar( - radius: 18, - backgroundImage: NetworkImage(widget.imageUrl), - ) - else - CircleAvatar( - radius: 18, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - Text( - status ?? "", - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - ], - ), + appBar: PreferredSize( + preferredSize: Size.fromHeight(kToolbarHeight), + child: InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => ChatInfoScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType) + ) + ), + child: AppBar( + backgroundColor: cs.surfaceContainerHigh, + foregroundColor: cs.onSurface, + elevation: 0, + surfaceTintColor: Colors.transparent, + iconTheme: IconThemeData(color: cs.onSurface), + leading: IconButton( + icon: const Icon(Symbols.arrow_back, weight: 400), + onPressed: () => Navigator.pop(context), ), - ], - ), - actions: [ - IconButton( - icon: const Icon(Symbols.call, weight: 400), - onPressed: () {}, + titleSpacing: 0, + title: Row( + children: [ + if (widget.imageUrl.isNotEmpty) + CircleAvatar( + radius: 18, + backgroundImage: NetworkImage(widget.imageUrl), + ) + else + CircleAvatar( + radius: 18, + backgroundColor: cs.primaryContainer, + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + Text( + status ?? "", + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Symbols.call, weight: 400), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Symbols.more_vert, weight: 400), + onPressed: () {}, + ), + ], ), - IconButton( - icon: const Icon(Symbols.more_vert, weight: 400), - onPressed: () {}, - ), - ], - ), + )), body: Column( children: [ Expanded( diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 1fc12c2..1217554 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -292,61 +292,67 @@ class MessageBubble extends StatelessWidget { String? senderAvatar = ContactCache.getAvatar(message.senderId); String? displaySender = ContactCache.get(message.senderId); - return Padding( - padding: EdgeInsets.only( - left: isMe ? 12 : 12, - right: isMe ? 12 : 12, - top: topMargin, - bottom: bottomMargin, - ), - child: Align( - child: Row( - mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (!isMe && chatType == "CHAT" && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) - ...(senderAvatar != null && senderAvatar.isNotEmpty) - ? [ - CircleAvatar( - radius: 15, - backgroundImage: NetworkImage(senderAvatar), - backgroundColor: cs.primaryContainer, - ) - ] - : [ - CircleAvatar( - radius: 15, - backgroundColor: cs.primaryContainer, - child: Text( - displaySender != null && displaySender.isNotEmpty - ? displaySender[0].toUpperCase() - : '?', - style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), - ), - ) - ] - else if (!isMe && chatType != "CHAT") - SizedBox(width: 0) - else if (!isMe) - CircleAvatar(radius: 15, backgroundColor: Color(0x00000000)), - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, + return GestureDetector( + // TODO: действия с сообщением + onTap: () => print("test"), + child: Padding( + padding: EdgeInsets.only( + left: isMe ? 12 : 12, + right: isMe ? 12 : 12, + top: topMargin, + bottom: bottomMargin, + ), + child: Align( + child: Row( + mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + spacing: 8, + + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMe && chatType == "CHAT" && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + ...(senderAvatar != null && senderAvatar.isNotEmpty) + ? [ + CircleAvatar( + radius: 15, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + ] + : [ + CircleAvatar( + radius: 15, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender != null && displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ) + ] + else if (!isMe && chatType != "CHAT") + SizedBox(width: 0) + else if (!isMe) + CircleAvatar(radius: 15, backgroundColor: Color(0x00000000)), + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE9E9EB)), + borderRadius: _borderRadius, + ), + padding: padding, + child: _buildContent(context), ), - decoration: BoxDecoration( - color: isMe - ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) - : (isDark - ? cs.surfaceContainerHighest - : const Color(0xFFE9E9EB)), - borderRadius: _borderRadius, - ), - padding: padding, - child: _buildContent(context), - ), - ], - ) - ), + ], + ) + ), + ) ); } From 72ac52b9b5b7dc95f0b55410ad66af48f70c6b9e Mon Sep 17 00:00:00 2001 From: prime Date: Wed, 29 Apr 2026 19:02:44 +1000 Subject: [PATCH 084/323] =?UTF-8?q?=D1=80=D1=83=D0=BA=D0=B8=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BB=D0=BE=D0=BC=D0=B0=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 +++++- lib/backend/api.dart | 6 ++++ lib/backend/models/chat_folder.dart | 2 +- lib/backend/modules/account.dart | 28 +++++++++------- lib/backend/modules/calls.dart | 4 +-- lib/backend/modules/chats.dart | 32 +++++++++++++------ lib/backend/modules/contacts.dart | 12 +++---- lib/backend/modules/messages.dart | 19 ++++++----- lib/core/protocol/packet.dart | 1 + lib/core/storage/app_database.dart | 28 +++++++++++++--- lib/core/storage/token_storage.dart | 2 +- lib/core/transport/connection.dart | 4 ++- lib/core/transport/proxy_connector.dart | 24 ++++++++++---- lib/core/transport/sender.dart | 2 +- .../screens/auth/server_settings_sheet.dart | 4 ++- .../screens/chats/chat_list_screen.dart | 1 + lib/frontend/screens/chats/chat_screen.dart | 2 +- .../screens/profile/devices_screen.dart | 5 ++- lib/main.dart | 13 +++++--- lib/models/attachment.dart | 12 ++++--- 20 files changed, 145 insertions(+), 66 deletions(-) diff --git a/.gitignore b/.gitignore index 14601b5..f395ff9 100644 --- a/.gitignore +++ b/.gitignore @@ -125,4 +125,12 @@ app.*.symbols !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -!/dev/ci/**/Gemfile.lock \ No newline at end of file +!/dev/ci/**/Gemfile.lock + +# AI / Agents +agents.md +.claude/ + +# Environment variables +.env +.env.* \ No newline at end of file diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 2c50950..495b56a 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -237,6 +237,11 @@ class Api { _dispatcher.registerHandler(opcode, handler); } + /// Снимает обработчик пушей с указанного опкода. + void unregisterPushHandler(int opcode) { + _dispatcher.unregisterHandler(opcode); + } + /// Стрим всех входящих пушей от сервера. Stream get pushStream => _dispatcher.pushStream; @@ -248,6 +253,7 @@ class Api { _connection.dispose(); _stateController.close(); _sessionExpiredController.close(); + _handshakeSuccessController.close(); } // Внутрянка diff --git a/lib/backend/models/chat_folder.dart b/lib/backend/models/chat_folder.dart index 0d6044a..8392a6c 100644 --- a/lib/backend/models/chat_folder.dart +++ b/lib/backend/models/chat_folder.dart @@ -25,7 +25,7 @@ class ChatFolder { factory ChatFolder.fromJson(Map json) { return ChatFolder( - id: json['id'].toString(), + id: json['id']?.toString() ?? '', title: json['title']?.toString() ?? '', emoji: json['emoji']?.toString(), include: (json['include'] as List?) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 1f57233..4c5c32d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -289,7 +289,7 @@ class LoginSyncParams { draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0, bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0, presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1, - lastLogin: int.parse(lastLogin), + lastLogin: int.tryParse(lastLogin) ?? 0, configHash: values[SyncKey.configHash], chatCacheFingerprint: values[SyncKey.chatCacheFingerprint], ); @@ -646,19 +646,25 @@ class AccountModule { Future _processProfileUpdate(Packet packet) async { _api.registerPushHandler(Opcode.notifProfile, (p) {}); - await for (final push in _api.pushStream.where( - (p) => p.opcode == Opcode.notifProfile, - )) { - final payload = push.payload; - if (payload is Map) { - final profile = payload['profile']; - if (profile is Map) { - final contact = profile['contact']; - if (contact is Map) { - return ProfileData.fromServerMap(contact.cast()); + try { + await for (final push in _api.pushStream + .where((p) => p.opcode == Opcode.notifProfile) + .timeout(const Duration(seconds: 15))) { + final payload = push.payload; + if (payload is Map) { + final profile = payload['profile']; + if (profile is Map) { + final contact = profile['contact']; + if (contact is Map) { + return ProfileData.fromServerMap(contact.cast()); + } } } } + } on TimeoutException { + throw Exception('Таймаут ожидания обновления профиля'); + } finally { + _api.unregisterPushHandler(Opcode.notifProfile); } throw Exception('Не удалось получить обновлённый профиль'); } diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 4593e48..cc2db6a 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -92,8 +92,8 @@ class CallsModule { msg['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(); - final name = contact?.firstName != null - ? '${contact!.firstName} ${contact.lastName ?? ''}'.trim() + final name = (contact != null && contact.firstName.isNotEmpty) + ? '${contact.firstName} ${contact.lastName ?? ''}'.trim() : 'Неизвестный'; extractedCalls.add( diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 3b0fb27..7ee2864 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -3,6 +3,21 @@ import 'dart:convert'; import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; +Map _parseParticipants(dynamic raw) { + try { + final decoded = raw is String ? jsonDecode(raw) : raw; + if (decoded is Map) { + return decoded.map((k, v) => MapEntry( + k is int ? k : int.parse(k.toString()), + v is int ? v : int.tryParse(v.toString()) ?? 0, + )); + } + } catch (e) { + logger.e('Failed to parse participants: $e'); + } + return {}; +} + class CachedChat { final int id; final int accountId; @@ -59,8 +74,7 @@ class CachedChat { dontDisturbUntil: row['dont_disturb_until'] as int, isOnline: (row['is_online'] as int) == 1, seenTime: row['seen_time'] as int, - // watafuc - participants: Map.from(jsonDecode(row['participants'])).map((k, v) => MapEntry(int.parse(k), v)) + participants: _parseParticipants(row['participants']) ); Map toDbRow() => { @@ -242,7 +256,7 @@ class ChatsModule { isOnline = (presence['status'] as int?) == 1; } } - Map participants = Map.from(chat['participants']); + Map participants = _parseParticipants(chat['participants']); return CachedChat( id: id, @@ -282,12 +296,12 @@ class ChatsModule { static String? _nameFromContact(Map contact) { final names = contact['names']; if (names is! List || names.isEmpty) return null; - final name = - names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.first, - ) - as Map; + final nameRaw = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), + ); + if (nameRaw is! Map) return null; + final name = nameRaw; return name['name'] as String?; } } diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 1943e52..71e4926 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -72,12 +72,12 @@ class ContactsModule { final names = contact['names']; if (names is List && names.isNotEmpty) { - final name = - names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.first, - ) - as Map; + final nameRaw = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), + ); + if (nameRaw is! Map) return null; + final name = nameRaw; firstName = (name['firstName'] as String?) ?? ''; lastName = name['lastName'] as String?; } diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 1526cac..255ff2e 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -155,7 +155,9 @@ class MessagesModule { } if (rows.isNotEmpty) { - AppDatabase.saveMessages(rows).ignore(); + AppDatabase.saveMessages(rows).catchError((e) { + debugPrint('saveMessages error: $e'); + }); } return results; @@ -257,9 +259,8 @@ class MessagesModule { if (data is! Map) return null; final content = data['content']; - if (content is String) { - return Uri.parse(content).host.isNotEmpty ? null : null; - } + if (content is Uint8List) return content; + if (content is List) return Uint8List.fromList(content); return null; } catch (e) { return null; @@ -295,9 +296,8 @@ class MessagesModule { if (data is! Map) return null; final content = data['content']; - if (content is String) { - return Uri.parse(content).host.isNotEmpty ? null : null; - } + if (content is Uint8List) return content; + if (content is List) return Uint8List.fromList(content); return null; } catch (e) { return null; @@ -333,9 +333,8 @@ class MessagesModule { if (data is! Map) return null; final content = data['content']; - if (content is String) { - return Uri.parse(content).host.isNotEmpty ? null : null; - } + if (content is Uint8List) return content; + if (content is List) return Uint8List.fromList(content); return null; } catch (e) { return null; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 7b6fb38..5637328 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -190,6 +190,7 @@ Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { if (pos >= src.length) break; + if (pos + 1 >= src.length) throw StateError('LZ4: unexpected end of input'); final offset = src[pos] | (src[pos + 1] << 8); pos += 2; if (offset == 0) throw StateError('LZ4: offset = 0'); diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 5890f21..53bbb91 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:komet/core/utils/logger.dart'; @@ -72,10 +73,15 @@ class ProfileData { final profileOptionsStr = row['profile_options'] as String?; List? profileOptions; if (profileOptionsStr != null && profileOptionsStr.isNotEmpty) { - profileOptions = profileOptionsStr - .split(',') - .map((e) => int.parse(e.trim())) - .toList(); + try { + profileOptions = profileOptionsStr + .split(',') + .where((e) => e.trim().isNotEmpty) + .map((e) => int.parse(e.trim())) + .toList(); + } catch (_) { + profileOptions = null; + } } return ProfileData( id: row['id'] as int, @@ -130,8 +136,20 @@ class AppDatabase { } } + static Completer? _initCompleter; + static Future get _instance async { - _db ??= await _open(); + if (_db != null) return _db!; + if (_initCompleter != null) return _initCompleter!.future; + _initCompleter = Completer(); + try { + _db = await _open(); + _initCompleter!.complete(_db!); + } catch (e) { + _initCompleter!.completeError(e); + _initCompleter = null; + rethrow; + } return _db!; } diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 75ab4bb..b04ed77 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -33,7 +33,7 @@ class TokenStorage { static Future readActiveToken() async { final id = await getActiveAccountId(); if (id == null) return null; - return readToken(id); + return await readToken(id); } static Future deleteAccount(int accountId) async { diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index 15c3a53..c0b0532 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -99,7 +99,9 @@ class Connection { if (socket != null) { try { socket.close(); - } catch (_) {} + } catch (e) { + logger.w('Ошибка при закрытии сокета: $e'); + } } _setState(SocketState.disconnected); diff --git a/lib/core/transport/proxy_connector.dart b/lib/core/transport/proxy_connector.dart index aaac29b..90d4df6 100644 --- a/lib/core/transport/proxy_connector.dart +++ b/lib/core/transport/proxy_connector.dart @@ -60,8 +60,8 @@ class ProxyConnector { if (!useAuth) { throw SocketException('SOCKS5: прокси требует аутентификацию'); } - final usernameBytes = utf8.encode(settings.username!); - final passwordBytes = utf8.encode(settings.password!); + final usernameBytes = utf8.encode(settings.username ?? ''); + final passwordBytes = utf8.encode(settings.password ?? ''); final authPacket = BytesBuilder() ..addByte(0x01) ..addByte(usernameBytes.length) @@ -175,7 +175,10 @@ class ProxyConnector { final responseStr = utf8.decode(headerBytes, allowMalformed: true); final statusLine = responseStr.split('\r\n').first; final parts = statusLine.split(' '); - final statusCode = parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0; + if (parts.length < 2) { + throw SocketException('HTTP CONNECT: некорректный ответ: $statusLine'); + } + final statusCode = int.tryParse(parts[1]) ?? 0; if (statusCode != 200) { throw SocketException( 'HTTP CONNECT: прокси вернул статус $statusCode', @@ -201,10 +204,17 @@ class ProxyConnector { RawSocket proxySocket, _RawSocketIO io, ) async { - final server = await RawServerSocket.bind( - InternetAddress.loopbackIPv4, - 0, - ); + RawServerSocket? server; + try { + server = await RawServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + ); + } catch (e) { + io.dispose(); + proxySocket.close(); + rethrow; + } final clientSide = await RawSocket.connect( InternetAddress.loopbackIPv4, server.port, diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index 69e8c5d..aaa3187 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -8,7 +8,7 @@ class PacketSender { int get currentSeq => _seq; int _nextSeq() { - _seq = (_seq + 1) % 256; + _seq = (_seq + 1) % 65536; return _seq; } diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart index 534338a..e458642 100644 --- a/lib/frontend/screens/auth/server_settings_sheet.dart +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -53,7 +55,7 @@ class _ServerSettingsSheetState extends State { await prefs.setString(ServerConfig.prefHostKey, host); await prefs.setInt(ServerConfig.prefPortKey, port); await api.disconnect(); - api.connect(); + unawaited(api.connect()); final online = await api.stateStream .firstWhere((s) => s == SessionState.online || s == SessionState.disconnected) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 9a9878b..be2dacf 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -638,6 +638,7 @@ class _ChatListScreenState extends State ..removeListener(_onStoriesRevealTick) ..removeStatusListener(_onStoriesRevealStatus) ..dispose(); + _shimmerController.dispose(); _folderPageController.dispose(); while (_folderChatScrollControllers.isNotEmpty) { final c = _folderChatScrollControllers.removeLast(); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 41069ac..22c8d0c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -381,7 +381,7 @@ class _ChatScreenState extends State myId: _myId, prevMessage: prevMessage, nextMessage: nextMessage, - chatType: chat!.type, + chatType: chat?.type ?? 'CHAT', ); }, ); diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 8030921..ab9b715 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -271,8 +271,9 @@ class _DevicesScreenState extends State setState(() => _loadingIps.add(id)); } + HttpClient? client; try { - final client = HttpClient(); + client = HttpClient(); client.connectionTimeout = const Duration(seconds: 5); final request = await client.getUrl( Uri.parse( @@ -296,6 +297,8 @@ class _DevicesScreenState extends State setState(() => _loadingIps.remove(id)); showCustomNotification(context, 'Ошибка IP: $e'); } + } finally { + client?.close(); } } diff --git a/lib/main.dart b/lib/main.dart index 0ce4998..5c62627 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -71,6 +73,7 @@ class KometAppState extends State { late Locale _locale; bool _isLoggingOut = false; + StreamSubscription? _sessionExpiredSub; late final ValueNotifier fpsOverlayEnabled = ValueNotifier( widget.initialFpsOverlay, ); @@ -83,15 +86,16 @@ class KometAppState extends State { api.setReconnectCallback(() async { try { final accountId = await TokenStorage.getActiveAccountId(); - if (accountId != null && - await TokenStorage.readToken(accountId) != null) { + if (accountId != null) { final token = await TokenStorage.readToken(accountId); - await accountModule.login(accountId: accountId, token: token); + if (token != null) { + await accountModule.login(accountId: accountId, token: token); + } } } catch (_) {} }); - api.sessionExpiredStream.listen((SessionExpiredException e) async { + _sessionExpiredSub = api.sessionExpiredStream.listen((SessionExpiredException e) async { if (_isLoggingOut) return; _isLoggingOut = true; @@ -118,6 +122,7 @@ class KometAppState extends State { @override void dispose() { + _sessionExpiredSub?.cancel(); fpsOverlayEnabled.dispose(); super.dispose(); } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 25ce0a6..825e348 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -136,7 +136,8 @@ class VideoAttachment extends MessageAttachment { } else if (previewRaw is List) { try { final bytes = List.from(previewRaw); - previewStr = String.fromCharCodes(bytes); + final base64 = String.fromCharCodes(bytes); + previewStr = 'data:image/webp;base64,$base64'; } catch (_) {} } @@ -192,7 +193,8 @@ class AudioAttachment extends MessageAttachment { } else if (previewRaw is List) { try { final bytes = List.from(previewRaw); - previewStr = String.fromCharCodes(bytes); + final base64 = String.fromCharCodes(bytes); + previewStr = 'data:image/webp;base64,$base64'; } catch (_) {} } @@ -244,7 +246,8 @@ class FileAttachment extends MessageAttachment { } else if (previewRaw is List) { try { final bytes = List.from(previewRaw); - previewStr = String.fromCharCodes(bytes); + final base64 = String.fromCharCodes(bytes); + previewStr = 'data:image/webp;base64,$base64'; } catch (_) {} } @@ -294,7 +297,8 @@ class StickerAttachment extends MessageAttachment { } else if (previewRaw is List) { try { final bytes = List.from(previewRaw); - previewStr = String.fromCharCodes(bytes); + final base64 = String.fromCharCodes(bytes); + previewStr = 'data:image/webp;base64,$base64'; } catch (_) {} } From a0d319a2488b9a52e60caf37a97ff46ba34053ad Mon Sep 17 00:00:00 2001 From: prime Date: Wed, 29 Apr 2026 19:21:53 +1000 Subject: [PATCH 085/323] =?UTF-8?q?=D0=BD=D1=83=20=D0=B7=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=20=D1=85=D1=83=D0=B9=D0=BD=D1=8E=20=D0=BD=D0=B0=D1=88=D0=B5?= =?UTF-8?q?=D0=BB.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/calls/calls_tab.dart | 8 +++++++- lib/frontend/screens/chats/chat_list_screen.dart | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 09edce7..7e08f23 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -30,7 +30,13 @@ class _CallsTabState extends State { } final callsModule = CallsModule(api); - final calls = await callsModule.fetchHistory(p.id, p.id); + List calls; + try { + calls = await callsModule.fetchHistory(p.id, p.id); + } catch (e) { + if (mounted) setState(() => _isLoading = false); + return; + } final List grouped = []; for (final call in calls) { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index be2dacf..b52a632 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1255,6 +1255,7 @@ class _ChatListScreenState extends State }); }, child: Stack( + clipBehavior: Clip.hardEdge, children: [ AnimatedPositioned( duration: _navDragging From ea3042f341e8e9a4ab732c8195a6b7df12f1f782 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 13 May 2026 21:26:46 +0700 Subject: [PATCH 086/323] =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BE=D0=B4=D1=80=D0=BE=D0=B1=D0=BD=D0=B5=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 120 ++++++ lib/backend/modules/chats.dart | 22 + lib/core/storage/app_database.dart | 14 + lib/core/storage/spoofing_service.dart | 2 +- .../screens/chats/chat_info_screen.dart | 379 ++++++++++++++++-- .../screens/profile/debug_menu_screen.dart | 132 +++++- lib/frontend/screens/profile/info_screen.dart | 286 +++++++++++++ .../screens/profile/security_screen.dart | 25 +- .../screens/profile/settings_tab.dart | 21 +- lib/l10n/app_en.arb | 57 ++- lib/l10n/app_localizations.dart | 324 +++++++++++++++ lib/l10n/app_localizations_en.dart | 162 ++++++++ lib/l10n/app_localizations_ru.dart | 162 ++++++++ lib/l10n/app_ru.arb | 57 ++- 14 files changed, 1724 insertions(+), 39 deletions(-) create mode 100644 lib/frontend/screens/profile/info_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 4c5c32d..8d6f6b8 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -27,6 +27,7 @@ class PrivacyConfig { final String chatsInvite; final bool pushNewContacts; final bool unsafeFiles; + final String phoneNumberPrivacy; final String inactiveTtl; final bool showReadMark; final bool altKeyboard; @@ -48,6 +49,7 @@ class PrivacyConfig { required this.chatsInvite, required this.pushNewContacts, required this.unsafeFiles, + required this.phoneNumberPrivacy, required this.inactiveTtl, required this.showReadMark, required this.altKeyboard, @@ -71,6 +73,7 @@ class PrivacyConfig { chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS', pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false, unsafeFiles: map['UNSAFE_FILES'] ?? true, + phoneNumberPrivacy: map['PHONE_NUMBER_PRIVACY']?.toString() ?? 'ALL', inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M', showReadMark: map['SHOW_READ_MARK'] ?? true, altKeyboard: map['ALT_KEYBOARD'] ?? false, @@ -94,6 +97,7 @@ class PrivacyConfig { 'CHATS_INVITE': chatsInvite, 'PUSH_NEW_CONTACTS': pushNewContacts, 'UNSAFE_FILES': unsafeFiles, + 'PHONE_NUMBER_PRIVACY': phoneNumberPrivacy, 'INACTIVE_TTL': inactiveTtl, 'SHOW_READ_MARK': showReadMark, 'ALT_KEYBOARD': altKeyboard, @@ -125,6 +129,7 @@ class PrivacyConfig { chatsInvite: 'CONTACTS', pushNewContacts: false, unsafeFiles: true, + phoneNumberPrivacy: 'ALL', inactiveTtl: '6M', showReadMark: true, altKeyboard: false, @@ -946,6 +951,12 @@ class AccountModule { logger.w('Папки чатов: $e'); } + try { + await _saveLoginInfo(data, profile.id); + } catch (e) { + logger.w('Info: $e'); + } + return LoginResult( profile: profile, updatedToken: updatedToken, @@ -980,6 +991,115 @@ class AccountModule { } } + Future _saveLoginInfo( + Map data, + int accountId, + ) async { + final contact = data['profile']?['contact'] as Map?; + final videoChatHistory = data['videoChatHistory']; + final chats = data['chats'] as List?; + final config = data['config'] as Map?; + final serverConfig = config?['server'] as Map?; + final userConfig = config?['user'] as Map?; + final yMap = serverConfig?['y-map'] as Map?; + final whiteListLinks = serverConfig?['white-list-links'] as List?; + final fileUploadUnsupported = serverConfig?['file-upload-unsupported-types'] as List?; + final time = data['time'] as int?; + + final info = { + 'registrationTime': contact?['registrationTime'], + 'country': contact?['country'], + 'videoChatHistory': videoChatHistory, + 'updateTime': contact?['updateTime'], + 'id': contact?['id'], + 'chatMarker': chats != null && chats.isNotEmpty + ? _extractChatMarker(chats.cast()) + : null, + 'time': time, + 'server': serverConfig != null + ? _extractServerInfo(serverConfig, yMap, whiteListLinks, fileUploadUnsupported) + : null, + 'user': userConfig != null ? _extractUserConfig(userConfig) : null, + }; + + await AppDatabase.saveLoginInfo(accountId, jsonEncode(info)); + } + + Map _extractChatMarker(List chats) { + int? latestTime; + for (final chat in chats) { + final lastEventTime = chat['lastEventTime'] as int?; + if (lastEventTime != null && (latestTime == null || lastEventTime > latestTime)) { + latestTime = lastEventTime; + } + } + return {'chatMarker': latestTime}; + } + + Map _extractServerInfo( + Map serverConfig, + Map? yMap, + List? whiteListLinks, + List? fileUploadUnsupported, + ) { + return { + 'account-removal-enabled': serverConfig['account-removal-enabled'], + 'image-size': serverConfig['image-size'], + 'gce': serverConfig['gce'], + 'gcce': serverConfig['gcce'], + 'max-msg-length': serverConfig['max-msg-length'], + 'quotes-enabled': serverConfig['quotes-enabled'], + 'calls-endpoint': serverConfig['calls-endpoint'], + 'send-location-enabled': serverConfig['send-location-enabled'], + 'lgce': serverConfig['lgce'], + 'wud': serverConfig['wud'], + 'video-msg-enabled': serverConfig['video-msg-enabled'], + 'grse': serverConfig['grse'], + 'edit-timeout': serverConfig['edit-timeout'], + 'image-quality': serverConfig['image-quality'], + 'unsafe-files-alert': serverConfig['unsafe-files-alert'], + 'account-nickname-enabled': serverConfig['account-nickname-enabled'], + 'mentions_entity_names_limit': serverConfig['mentions_entity_names_limit'], + 'reactions-enabled': serverConfig['reactions-enabled'], + 'y-map': yMap != null ? { + 'tile': yMap['tile'], + 'geocoder': yMap['geocoder'], + 'static': yMap['static'], + } : null, + 'white-list-links': whiteListLinks, + 'file-upload-unsupported-types': fileUploadUnsupported, + }; + } + + Map _extractUserConfig(Map userConfig) { + return { + 'CHATS_PUSH_NOTIFICATION': userConfig['CHATS_PUSH_NOTIFICATION'], + 'PUSH_DETAILS': userConfig['PUSH_DETAILS'], + 'PUSH_SOUND': userConfig['PUSH_SOUND'], + 'PHONE_NUMBER_PRIVACY': userConfig['PHONE_NUMBER_PRIVACY'], + 'INACTIVE_TTL': userConfig['INACTIVE_TTL'], + 'SHOW_READ_MARK': userConfig['SHOW_READ_MARK'], + 'AUDIO_TRANSCRIPTION_ENABLED': userConfig['AUDIO_TRANSCRIPTION_ENABLED'], + 'SEARCH_BY_PHONE': userConfig['SEARCH_BY_PHONE'], + 'INCOMING_CALL': userConfig['INCOMING_CALL'], + 'DOUBLE_TAP_REACTION_DISABLED': userConfig['DOUBLE_TAP_REACTION_DISABLED'], + 'SAFE_MODE_NO_PIN': userConfig['SAFE_MODE_NO_PIN'], + 'CHATS_PUSH_SOUND': userConfig['CHATS_PUSH_SOUND'], + 'DOUBLE_TAP_REACTION_VALUE': userConfig['DOUBLE_TAP_REACTION_VALUE'], + 'FAMILY_PROTECTION': userConfig['FAMILY_PROTECTION'], + 'HIDDEN': userConfig['HIDDEN'], + 'CHATS_INVITE': userConfig['CHATS_INVITE'], + 'PUSH_NEW_CONTACTS': userConfig['PUSH_NEW_CONTACTS'], + 'UNSAFE_FILES': userConfig['UNSAFE_FILES'], + 'DONT_DISTURB_UNTIL': userConfig['DONT_DISTURB_UNTIL'], + 'ALT_KEYBOARD': userConfig['ALT_KEYBOARD'], + 'CONTENT_LEVEL_ACCESS': userConfig['CONTENT_LEVEL_ACCESS'], + 'STICKERS_SUGGEST': userConfig['STICKERS_SUGGEST'], + 'SAFE_MODE': userConfig['SAFE_MODE'], + 'M_CALL_PUSH_NOTIFICATION': userConfig['M_CALL_PUSH_NOTIFICATION'], + }; + } + Future _requestCodeInternal( String phone, AuthRequestType type, diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 7ee2864..f47aa70 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; +import '../api.dart'; Map _parseParticipants(dynamic raw) { try { @@ -304,4 +306,24 @@ class ChatsModule { final name = nameRaw; return name['name'] as String?; } + + static Future?> getChatInfo(Api api, int chatId) async { + final packet = await api.sendRequest(Opcode.chatInfo, { + 'chatIds': [chatId], + }); + if (packet.isError) return null; + final payload = packet.payload as Map?; + final chats = payload?['chats'] as List?; + if (chats == null || chats.isEmpty) return null; + return Map.from(chats.first as Map); + } + + static Future searchById(Api api, int userId) async { + final packet = await api.sendRequest(Opcode.publicSearch, { + 'query': userId.toString(), + 'from': 0, + 'count': 10, + }); + return packet.payload; + } } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 53bbb91..7283d9b 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -124,6 +124,7 @@ abstract class SyncKey { static const configHash = 'config_hash'; static const chatCacheFingerprint = 'chat_cache_fingerprint'; static const serverTime = 'server_time'; + static const loginInfo = 'login_info'; } class AppDatabase { @@ -392,6 +393,19 @@ class AppDatabase { return rows.first['value'] as String; } + static Future saveLoginInfo(int accountId, String jsonInfo) async { + final db = await _instance; + await db.insert('sync_state', { + 'account_id': accountId, + 'key': SyncKey.loginInfo, + 'value': jsonInfo, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getLoginInfo(int accountId) async { + return getSyncValue(accountId, SyncKey.loginInfo); + } + static Future close() async { await _db?.close(); _db = null; diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index b58cce2..c27240b 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -1,7 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class SpoofingService { - static const String hardcodedAppVersion = '26.8.1'; + static const String hardcodedAppVersion = '26.15.3'; static const int hardcodedBuildNumber = 6606; static Future?> getSpoofedSessionData() async { diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index c50ae93..70a4ae7 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -1,4 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/protocol/opcode_map.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart' as main; +import '../../widgets/custom_notification.dart'; class ChatInfoScreen extends StatefulWidget { final int chatId; @@ -13,49 +18,367 @@ class ChatInfoScreen extends StatefulWidget { required this.imageUrl, required this.chatType, }); + @override State createState() => _ChatInfoScreenState(); } -class _ChatInfoScreenState extends State{ + +class _ChatInfoScreenState extends State + with TickerProviderStateMixin { + bool _isLoading = true; + Map? _chatData; + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadChatData(); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + Future _loadChatData() async { + try { + final packet = await main.api.sendRequest(Opcode.chatInfo, { + 'chatIds': [widget.chatId], + }); + + final payload = packet.payload as Map?; + if (payload == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + + final errorField = payload['error']; + if (errorField != null) { + String errorMsg = 'Error'; + if (errorField is Map) { + errorMsg = errorField['localizedMessage'] ?? errorField['message'] ?? errorField.toString(); + } else if (errorField is String) { + errorMsg = errorField; + } + if (mounted) showCustomNotification(context, errorMsg); + setState(() => _isLoading = false); + return; + } + + final chats = payload['chats'] as List?; + if (chats != null && chats.isNotEmpty) { + _chatData = Map.from(chats.first as Map); + } else if (chats != null && chats.isEmpty) { + if (mounted) showCustomNotification(context, 'No data found'); + } + + if (mounted) setState(() => _isLoading = false); + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Error: $e'); + setState(() => _isLoading = false); + } + } + } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); return Scaffold( backgroundColor: cs.surface, appBar: AppBar( - + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + l10n?.chatInfoTitle ?? 'Info', + style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), + ), ), - body: - SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (widget.imageUrl.isNotEmpty) - CircleAvatar( - radius: 36, - backgroundImage: NetworkImage(widget.imageUrl), - ) - else - CircleAvatar( - radius: 36, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), - ), - ), - Text( - widget.name, - style: TextStyle(color: Colors.white, fontSize: 24, height: 1.3), + body: _isLoading + ? _buildShimmer(cs) + : _chatData == null + ? Center( + child: Text( + 'No data', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ) + : _buildContent(cs, l10n), + ); + } + + Widget _buildShimmer(ColorScheme cs) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + shape: BoxShape.circle, ), - - ], + ), + ), + const SizedBox(height: 12), + Center( + child: Container( + width: 120, + height: 20, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(6), + ), + ), + ), + const SizedBox(height: 24), + ...List.generate( + 10, + (_) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + height: 48, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ); + } + + Widget _buildContent(ColorScheme cs, AppLocalizations? l10n) { + final chat = _chatData!; + final type = chat['type'] as String? ?? ''; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primaryContainer, + ), + child: widget.imageUrl.isNotEmpty + ? ClipOval( + child: Image.network(widget.imageUrl, fit: BoxFit.cover), + ) + : Center( + child: Text( + widget.name.isNotEmpty + ? widget.name[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Center( + child: Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 24), + + if (type == 'CHANNEL') ...[ + _buildSectionTitle('Channel', cs), + _buildRow( + l10n?.chatInfoSubscribers ?? 'subscribers:', + (chat['participantsCount'] as int?)?.toString() ?? '-', + cs, + ), + if ((chat['link'] as String?)?.isNotEmpty ?? false) + _buildRow( + l10n?.chatInfoLink ?? 'link:', + chat['link'] as String, + cs, + ), + _buildRow( + l10n?.chatInfoOfficial ?? 'official:', + (chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-', + cs, + ), + _buildRow( + l10n?.chatInfoComments ?? 'comments:', + (chat['options']?['COMMENTS'] as bool?)?.toString() ?? '-', + cs, + ), + _buildRow( + l10n?.chatInfoAplus ?? 'approved by Roskomnadzor:', + (chat['options']?['A_PLUS_CHANNEL'] as bool?)?.toString() ?? '-', + cs, + ), + _buildRow( + l10n?.chatInfoSignAdmin ?? 'admin signature:', + (chat['options']?['SIGN_ADMIN'] as bool?)?.toString() ?? '-', + cs, + ), + if ((chat['modified'] as int?) != null) + _buildRow( + l10n?.chatInfoLastChanged ?? 'last changed:', + _formatTs(chat['modified'] as int), + cs, + ), + if ((chat['created'] as int?) != null) + _buildRow( + l10n?.chatInfoCreated ?? 'created:', + _formatTs(chat['created'] as int), + cs, + ), + ], + + if (type == 'CHAT') ...[ + _buildSectionTitle('Chat', cs), + _buildRow( + l10n?.chatInfoMembers ?? 'members:', + (chat['participantsCount'] as int?)?.toString() ?? '-', + cs, + ), + if ((chat['hasBots'] as bool?) ?? false) + _buildRow( + l10n?.chatInfoHasBots ?? 'has bots:', + 'true', + cs, + ), + if ((chat['blockedParticipantsCount'] as int?) != null && + chat['blockedParticipantsCount'] > 0) + _buildRow( + l10n?.chatInfoBlockedCount ?? 'blocked in group:', + (chat['blockedParticipantsCount'] as int).toString(), + cs, + ), + _buildRow( + l10n?.chatInfoOfficialStatus ?? 'official status:', + (chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-', + cs, + ), + if ((chat['modified'] as int?) != null) + _buildRow( + l10n?.chatInfoLastChanged ?? 'last changed:', + _formatTs(chat['modified'] as int), + cs, + ), + if ((chat['joinTime'] as int?) != null && chat['joinTime'] != 1) + _buildRow( + l10n?.chatInfoJoined ?? 'joined:', + _formatTs(chat['joinTime'] as int), + cs, + ), + if ((chat['created'] as int?) != null) + _buildRow( + l10n?.chatInfoGroupCreated ?? 'group created:', + _formatTs(chat['created'] as int), + cs, + ), + if ((chat['owner'] as int?) != null) + _buildRow( + l10n?.chatInfoGroupOwner ?? 'group owner:', + (chat['owner'] as int).toString(), + cs, + ), + ], + + if (type == 'DIALOG') ...[ + if ((chat['created'] as int?) != null && + chat['created'] != 0 && + chat['created'] != 1) + _buildRow( + l10n?.chatInfoDialogStarted ?? 'dialog started:', + _formatTs(chat['created'] as int), + cs, + ), + ], + + const SizedBox(height: 120), + ], + ); + } + + Widget _buildSectionTitle(String title, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), + child: Text( + title, + style: TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, ), ), ); } -} + Widget _buildRow(String label, String value, ColorScheme cs) { + return Container( + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + flex: 2, + child: Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 3, + child: Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + } + + String _formatTs(int ts) { + if (ts < 1000000000000) return ts.toString(); + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } +} \ No newline at end of file diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 5f7de26..fc43f31 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -1,11 +1,55 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; - +import '../../../backend/modules/chats.dart'; +import '../../../core/utils/logger.dart'; import '../../../main.dart'; -class DebugMenuScreen extends StatelessWidget { +class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); + @override + State createState() => _DebugMenuScreenState(); +} + +class _DebugMenuScreenState extends State { + final _idController = TextEditingController(); + String? _searchResult; + bool _isSearching = false; + + @override + void dispose() { + _idController.dispose(); + super.dispose(); + } + + Future _search() async { + final id = int.tryParse(_idController.text); + if (id == null) return; + setState(() { + _isSearching = true; + _searchResult = null; + }); + try { + final result = await ChatsModule.searchById(api, id); + logger.i('searchById result: $result'); + if (!mounted) return; + if (result is Map && result.containsKey('error')) { + final errorMsg = result['localizedMessage'] ?? result['message'] ?? result['error'] ?? 'Error'; + setState(() => _searchResult = 'Error: $errorMsg'); + } else if (result is Map) { + setState(() => _searchResult = result.toString()); + } else { + setState(() => _searchResult = result?.toString() ?? 'null'); + } + } catch (e) { + if (mounted) { + setState(() => _searchResult = 'Exception: $e'); + } + } finally { + if (mounted) setState(() => _isSearching = false); + } + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -115,10 +159,92 @@ class DebugMenuScreen extends StatelessWidget { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Поиск по ID (opcode 60)', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _idController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Введите user ID', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onSubmitted: (_) => _search(), + ), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: _isSearching ? null : _search, + child: _isSearching + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Symbols.search, size: 20), + ), + ], + ), + if (_searchResult != null) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + constraints: const BoxConstraints(maxHeight: 400), + child: SingleChildScrollView( + child: Text( + _searchResult!, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + ), + ], + ], + ), + ), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 120)), ], ), ), ); } -} +} \ No newline at end of file diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart new file mode 100644 index 0000000..715ccd1 --- /dev/null +++ b/lib/frontend/screens/profile/info_screen.dart @@ -0,0 +1,286 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../core/storage/token_storage.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../widgets/custom_notification.dart'; + +class InfoScreen extends StatefulWidget { + const InfoScreen({super.key}); + + @override + State createState() => _InfoScreenState(); +} + +class _InfoScreenState extends State { + bool _isLoading = true; + Map? _info; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + try { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + final jsonStr = await AppDatabase.getLoginInfo(accountId); + if (jsonStr != null) { + setState(() => _info = jsonDecode(jsonStr) as Map); + } + if (mounted) setState(() => _isLoading = false); + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Error: $e'); + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + l10n?.infoTitle ?? 'Info', + style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _info == null + ? Center( + child: Text( + 'No data', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ) + : _buildContent(cs, l10n!), + ); + } + + Widget _buildContent(ColorScheme cs, AppLocalizations l10n) { + final info = _info!; + final server = info['server'] as Map?; + final user = info['user'] as Map?; + final yMap = server?['y-map'] as Map?; + + final accountKeys = { + 'registrationTime': l10n.infoRegistrationTime, + 'country': l10n.infoCountry, + 'videoChatHistory': l10n.infoVideoChatHistory, + 'updateTime': l10n.infoUpdateTime, + 'id': l10n.infoId, + 'chatMarker': l10n.infoChatMarker, + }; + + final serverKeys = { + 'account-removal-enabled': l10n.infoAccountRemovalEnabled, + 'image-size': l10n.infoImageSize, + 'gce': l10n.infoGce, + 'gcce': l10n.infoGcce, + 'max-msg-length': l10n.infoMaxMsgLength, + 'quotes-enabled': l10n.infoQuotesEnabled, + 'calls-endpoint': l10n.infoCallsEndpoint, + 'send-location-enabled': l10n.infoSendLocationEnabled, + 'lgce': l10n.infoLgce, + 'wud': l10n.infoWud, + 'video-msg-enabled': l10n.infoVideoMsgEnabled, + 'grse': l10n.infoGrse, + 'edit-timeout': l10n.infoEditTimeout, + 'image-quality': l10n.infoImageQuality, + 'unsafe-files-alert': l10n.infoUnsafeFilesAlert, + 'account-nickname-enabled': l10n.infoAccountNicknameEnabled, + 'mentions_entity_names_limit': l10n.infoMentionsEntityNamesLimit, + 'reactions-enabled': l10n.infoReactionsEnabled, + }; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSectionTitle(l10n.infoAccountSection, cs), + ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key]), cs)), + + const SizedBox(height: 16), + _buildSectionTitle(l10n.infoServerSection, cs), + ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key]), cs)), + + const SizedBox(height: 8), + _buildSectionTitle(l10n.infoYMapSection, cs), + _buildRow('tile', l10n.infoTile, yMap?['tile']?.toString() ?? '-', cs), + _buildRow('geocoder', l10n.infoGeocoder, yMap?['geocoder']?.toString() ?? '-', cs), + _buildRow('static', l10n.infoStatic, yMap?['static']?.toString() ?? '-', cs), + + const SizedBox(height: 8), + _buildSectionTitle(l10n.infoFileUploadTypes, cs), + _buildListRow(server?['file-upload-unsupported-types'] as List?, cs), + + const SizedBox(height: 8), + _buildSectionTitle(l10n.infoWhiteListLinks, cs), + _buildListRow(server?['white-list-links'] as List?, cs), + + const SizedBox(height: 8), + _buildSectionTitle(l10n.infoUserSection, cs), + if (user != null) + ...user.entries + .where((e) => e.value != null) + .map((e) => _buildRow(e.key, e.key, e.value.toString(), cs)), + + const SizedBox(height: 120), + ], + ); + } + + Widget _buildSectionTitle(String title, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), + child: Text( + title, + style: TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + } + + Widget _buildRow(String key, String label, String value, ColorScheme cs) { + return Container( + margin: const EdgeInsets.only(bottom: 1), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + flex: 2, + child: Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 3, + child: Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + } + + Widget _buildListRow(List? items, ColorScheme cs) { + if (items == null || items.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Text('-', style: TextStyle(color: cs.onSurfaceVariant)), + ); + } + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: items + .map( + (item) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + item.toString(), + style: TextStyle(fontSize: 13, color: cs.onSurface), + ), + ), + ) + .toList(), + ), + ); + } + + String _formatValue(dynamic value) { + if (value == null) return '-'; + if (value is Map && value.containsKey('chatMarker')) { + final ts = value['chatMarker'] as int?; + if (ts != null) { + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } + return '-'; + } + if (value is int && value > 1000000000000) { + final dt = DateTime.fromMillisecondsSinceEpoch(value); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } + if (value is int && value > 86400) { + final weeks = value ~/ 604800; + final days = (value % 604800) ~/ 86400; + if (weeks > 0) { + return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); + } + final h = value ~/ 3600; + final m = (value % 3600) ~/ 60; + if (h > 0) return '${h}h ${m}m'; + return '${m}m'; + } + return value.toString(); + } + + String _w(int n) { + final m = n % 10; + if (m == 1 && n != 11) return 'нед'; + if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'нед'; + return 'нед'; + } + + String _d(int n) { + final m = n % 10; + if (m == 1 && n != 11) return 'дн'; + if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'дн'; + return 'дн'; + } +} \ No newline at end of file diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 676396e..e65ab62 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -222,6 +222,7 @@ class _SecurityScreenState extends State case 'CONTACTS': return 'Мои контакты'; case 'NONE': + case 'NOBODY': return 'Никто'; default: return value; @@ -481,9 +482,31 @@ class _SecurityScreenState extends State icon: Icons.visibility_off_outlined, label: 'Видеть статус «в сети»', value: _privacyConfig?.hidden == true ? 'Никто' : 'Мои контакты', - isLast: true, + isLast: false, onTap: () => _showHiddenStatusSheet(context, cs), ), + _buildOptionRow( + cs, + icon: Symbols.contact_page, + label: 'Видеть мой номер', + value: _getPrivacyLabel( + _privacyConfig?.phoneNumberPrivacy ?? 'ALL', + ), + isLast: true, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Видеть мой номер', + currentValue: _privacyConfig?.phoneNumberPrivacy ?? 'ALL', + options: const [ + ('ALL', 'Все'), + ('CONTACTS', 'Мои контакты'), + ('NOBODY', 'Никто'), + ], + onSelect: (value) => + _updateSetting('PHONE_NUMBER_PRIVACY', value), + ), + ), ], ], ), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 2389838..3804ac8 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -8,6 +8,7 @@ import '../../../l10n/app_localizations.dart'; import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; +import 'info_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -96,15 +97,27 @@ class _SettingsTabState extends State { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildSection( +child: _buildSection( context, cs, - items: const [ - _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), - _SettingsItem( + items: [ + const _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), + const _SettingsItem( icon: Symbols.language, label: 'Войти в Сферум', ), + _SettingsItem( + icon: Symbols.info, + label: 'Info', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const InfoScreen(), + ), + ); + }, + ), ], ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 806d0c5..174459b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -104,5 +104,60 @@ } } }, - "profileMenuSpoof": "Spoofing" + "profileMenuSpoof": "Spoofing", + "infoTitle": "Info", + "infoAccountSection": "Account", + "infoServerSection": "Server", + "infoUserSection": "User", + "infoYMapSection": "Y-Map", + "infoFileUploadTypes": "file-upload-unsupported-types", + "infoWhiteListLinks": "white-list-links", + "infoRegistrationTime": "registrationTime", + "infoCountry": "country", + "infoVideoChatHistory": "videoChatHistory", + "infoUpdateTime": "updateTime", + "infoId": "id", + "infoChatMarker": "chatMarker", + "infoAccountRemovalEnabled": "account-removal-enabled", + "infoImageSize": "image-size", + "infoGce": "gce", + "infoGcce": "gcce", + "infoMaxMsgLength": "max-msg-length", + "infoQuotesEnabled": "quotes-enabled", + "infoCallsEndpoint": "calls-endpoint", + "infoSendLocationEnabled": "send-location-enabled", + "infoLgce": "lgce", + "infoWud": "wud", + "infoVideoMsgEnabled": "video-msg-enabled", + "infoGrse": "grse", + "infoEditTimeout": "edit-timeout", + "infoImageQuality": "image-quality", + "infoUnsafeFilesAlert": "unsafe-files-alert", + "infoAccountNicknameEnabled": "account-nickname-enabled", + "infoMentionsEntityNamesLimit": "mentions_entity_names_limit", + "infoReactionsEnabled": "reactions-enabled", + "infoTile": "tile", + "infoGeocoder": "geocoder", + "infoStatic": "static", + "chatInfoSubscribers": "subscribers:", + "chatInfoInvitedBy": "invited by:", + "chatInfoLink": "link:", + "chatInfoOfficial": "official:", + "chatInfoComments": "comments:", + "chatInfoAplus": "approved by Roskomnadzor:", + "chatInfoSignAdmin": "admin signature:", + "chatInfoLastChanged": "last changed:", + "chatInfoJoinTime": "joined:", + "chatInfoCreated": "created:", + "chatInfoTitle": "Info", + "chatInfoMembers": "members:", + "chatInfoLastSeen": "last seen recently", + "chatInfoHasBots": "has bots:", + "chatInfoBlockedCount": "blocked in group:", + "chatInfoOfficialStatus": "official status:", + "chatInfoLastChanged": "last changed:", + "chatInfoJoined": "joined:", + "chatInfoGroupCreated": "group created:", + "chatInfoGroupOwner": "group owner:", + "chatInfoDialogStarted": "dialog started:" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5bd5013..15fbdfb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -631,6 +631,330 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Spoofing'** String get profileMenuSpoof; + + /// No description provided for @infoTitle. + /// + /// In en, this message translates to: + /// **'Info'** + String get infoTitle; + + /// No description provided for @infoAccountSection. + /// + /// In en, this message translates to: + /// **'Account'** + String get infoAccountSection; + + /// No description provided for @infoServerSection. + /// + /// In en, this message translates to: + /// **'Server'** + String get infoServerSection; + + /// No description provided for @infoUserSection. + /// + /// In en, this message translates to: + /// **'User'** + String get infoUserSection; + + /// No description provided for @infoYMapSection. + /// + /// In en, this message translates to: + /// **'Y-Map'** + String get infoYMapSection; + + /// No description provided for @infoFileUploadTypes. + /// + /// In en, this message translates to: + /// **'file-upload-unsupported-types'** + String get infoFileUploadTypes; + + /// No description provided for @infoWhiteListLinks. + /// + /// In en, this message translates to: + /// **'white-list-links'** + String get infoWhiteListLinks; + + /// No description provided for @infoRegistrationTime. + /// + /// In en, this message translates to: + /// **'registrationTime'** + String get infoRegistrationTime; + + /// No description provided for @infoCountry. + /// + /// In en, this message translates to: + /// **'country'** + String get infoCountry; + + /// No description provided for @infoVideoChatHistory. + /// + /// In en, this message translates to: + /// **'videoChatHistory'** + String get infoVideoChatHistory; + + /// No description provided for @infoUpdateTime. + /// + /// In en, this message translates to: + /// **'updateTime'** + String get infoUpdateTime; + + /// No description provided for @infoId. + /// + /// In en, this message translates to: + /// **'id'** + String get infoId; + + /// No description provided for @infoChatMarker. + /// + /// In en, this message translates to: + /// **'chatMarker'** + String get infoChatMarker; + + /// No description provided for @infoAccountRemovalEnabled. + /// + /// In en, this message translates to: + /// **'account-removal-enabled'** + String get infoAccountRemovalEnabled; + + /// No description provided for @infoImageSize. + /// + /// In en, this message translates to: + /// **'image-size'** + String get infoImageSize; + + /// No description provided for @infoGce. + /// + /// In en, this message translates to: + /// **'gce'** + String get infoGce; + + /// No description provided for @infoGcce. + /// + /// In en, this message translates to: + /// **'gcce'** + String get infoGcce; + + /// No description provided for @infoMaxMsgLength. + /// + /// In en, this message translates to: + /// **'max-msg-length'** + String get infoMaxMsgLength; + + /// No description provided for @infoQuotesEnabled. + /// + /// In en, this message translates to: + /// **'quotes-enabled'** + String get infoQuotesEnabled; + + /// No description provided for @infoCallsEndpoint. + /// + /// In en, this message translates to: + /// **'calls-endpoint'** + String get infoCallsEndpoint; + + /// No description provided for @infoSendLocationEnabled. + /// + /// In en, this message translates to: + /// **'send-location-enabled'** + String get infoSendLocationEnabled; + + /// No description provided for @infoLgce. + /// + /// In en, this message translates to: + /// **'lgce'** + String get infoLgce; + + /// No description provided for @infoWud. + /// + /// In en, this message translates to: + /// **'wud'** + String get infoWud; + + /// No description provided for @infoVideoMsgEnabled. + /// + /// In en, this message translates to: + /// **'video-msg-enabled'** + String get infoVideoMsgEnabled; + + /// No description provided for @infoGrse. + /// + /// In en, this message translates to: + /// **'grse'** + String get infoGrse; + + /// No description provided for @infoEditTimeout. + /// + /// In en, this message translates to: + /// **'edit-timeout'** + String get infoEditTimeout; + + /// No description provided for @infoImageQuality. + /// + /// In en, this message translates to: + /// **'image-quality'** + String get infoImageQuality; + + /// No description provided for @infoUnsafeFilesAlert. + /// + /// In en, this message translates to: + /// **'unsafe-files-alert'** + String get infoUnsafeFilesAlert; + + /// No description provided for @infoAccountNicknameEnabled. + /// + /// In en, this message translates to: + /// **'account-nickname-enabled'** + String get infoAccountNicknameEnabled; + + /// No description provided for @infoMentionsEntityNamesLimit. + /// + /// In en, this message translates to: + /// **'mentions_entity_names_limit'** + String get infoMentionsEntityNamesLimit; + + /// No description provided for @infoReactionsEnabled. + /// + /// In en, this message translates to: + /// **'reactions-enabled'** + String get infoReactionsEnabled; + + /// No description provided for @infoTile. + /// + /// In en, this message translates to: + /// **'tile'** + String get infoTile; + + /// No description provided for @infoGeocoder. + /// + /// In en, this message translates to: + /// **'geocoder'** + String get infoGeocoder; + + /// No description provided for @infoStatic. + /// + /// In en, this message translates to: + /// **'static'** + String get infoStatic; + + /// No description provided for @chatInfoSubscribers. + /// + /// In en, this message translates to: + /// **'subscribers:'** + String get chatInfoSubscribers; + + /// No description provided for @chatInfoInvitedBy. + /// + /// In en, this message translates to: + /// **'invited by:'** + String get chatInfoInvitedBy; + + /// No description provided for @chatInfoLink. + /// + /// In en, this message translates to: + /// **'link:'** + String get chatInfoLink; + + /// No description provided for @chatInfoOfficial. + /// + /// In en, this message translates to: + /// **'official:'** + String get chatInfoOfficial; + + /// No description provided for @chatInfoComments. + /// + /// In en, this message translates to: + /// **'comments:'** + String get chatInfoComments; + + /// No description provided for @chatInfoAplus. + /// + /// In en, this message translates to: + /// **'approved by Roskomnadzor:'** + String get chatInfoAplus; + + /// No description provided for @chatInfoSignAdmin. + /// + /// In en, this message translates to: + /// **'admin signature:'** + String get chatInfoSignAdmin; + + /// No description provided for @chatInfoLastChanged. + /// + /// In en, this message translates to: + /// **'last changed:'** + String get chatInfoLastChanged; + + /// No description provided for @chatInfoJoinTime. + /// + /// In en, this message translates to: + /// **'joined:'** + String get chatInfoJoinTime; + + /// No description provided for @chatInfoCreated. + /// + /// In en, this message translates to: + /// **'created:'** + String get chatInfoCreated; + + /// No description provided for @chatInfoTitle. + /// + /// In en, this message translates to: + /// **'Info'** + String get chatInfoTitle; + + /// No description provided for @chatInfoMembers. + /// + /// In en, this message translates to: + /// **'members:'** + String get chatInfoMembers; + + /// No description provided for @chatInfoLastSeen. + /// + /// In en, this message translates to: + /// **'last seen recently'** + String get chatInfoLastSeen; + + /// No description provided for @chatInfoHasBots. + /// + /// In en, this message translates to: + /// **'has bots:'** + String get chatInfoHasBots; + + /// No description provided for @chatInfoBlockedCount. + /// + /// In en, this message translates to: + /// **'blocked in group:'** + String get chatInfoBlockedCount; + + /// No description provided for @chatInfoOfficialStatus. + /// + /// In en, this message translates to: + /// **'official status:'** + String get chatInfoOfficialStatus; + + /// No description provided for @chatInfoJoined. + /// + /// In en, this message translates to: + /// **'joined:'** + String get chatInfoJoined; + + /// No description provided for @chatInfoGroupCreated. + /// + /// In en, this message translates to: + /// **'group created:'** + String get chatInfoGroupCreated; + + /// No description provided for @chatInfoGroupOwner. + /// + /// In en, this message translates to: + /// **'group owner:'** + String get chatInfoGroupOwner; + + /// No description provided for @chatInfoDialogStarted. + /// + /// In en, this message translates to: + /// **'dialog started:'** + String get chatInfoDialogStarted; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index bfce511..0320c05 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -289,4 +289,166 @@ class AppLocalizationsEn extends AppLocalizations { @override String get profileMenuSpoof => 'Spoofing'; + + @override + String get infoTitle => 'Info'; + + @override + String get infoAccountSection => 'Account'; + + @override + String get infoServerSection => 'Server'; + + @override + String get infoUserSection => 'User'; + + @override + String get infoYMapSection => 'Y-Map'; + + @override + String get infoFileUploadTypes => 'file-upload-unsupported-types'; + + @override + String get infoWhiteListLinks => 'white-list-links'; + + @override + String get infoRegistrationTime => 'registrationTime'; + + @override + String get infoCountry => 'country'; + + @override + String get infoVideoChatHistory => 'videoChatHistory'; + + @override + String get infoUpdateTime => 'updateTime'; + + @override + String get infoId => 'id'; + + @override + String get infoChatMarker => 'chatMarker'; + + @override + String get infoAccountRemovalEnabled => 'account-removal-enabled'; + + @override + String get infoImageSize => 'image-size'; + + @override + String get infoGce => 'gce'; + + @override + String get infoGcce => 'gcce'; + + @override + String get infoMaxMsgLength => 'max-msg-length'; + + @override + String get infoQuotesEnabled => 'quotes-enabled'; + + @override + String get infoCallsEndpoint => 'calls-endpoint'; + + @override + String get infoSendLocationEnabled => 'send-location-enabled'; + + @override + String get infoLgce => 'lgce'; + + @override + String get infoWud => 'wud'; + + @override + String get infoVideoMsgEnabled => 'video-msg-enabled'; + + @override + String get infoGrse => 'grse'; + + @override + String get infoEditTimeout => 'edit-timeout'; + + @override + String get infoImageQuality => 'image-quality'; + + @override + String get infoUnsafeFilesAlert => 'unsafe-files-alert'; + + @override + String get infoAccountNicknameEnabled => 'account-nickname-enabled'; + + @override + String get infoMentionsEntityNamesLimit => 'mentions_entity_names_limit'; + + @override + String get infoReactionsEnabled => 'reactions-enabled'; + + @override + String get infoTile => 'tile'; + + @override + String get infoGeocoder => 'geocoder'; + + @override + String get infoStatic => 'static'; + + @override + String get chatInfoSubscribers => 'subscribers:'; + + @override + String get chatInfoInvitedBy => 'invited by:'; + + @override + String get chatInfoLink => 'link:'; + + @override + String get chatInfoOfficial => 'official:'; + + @override + String get chatInfoComments => 'comments:'; + + @override + String get chatInfoAplus => 'approved by Roskomnadzor:'; + + @override + String get chatInfoSignAdmin => 'admin signature:'; + + @override + String get chatInfoLastChanged => 'last changed:'; + + @override + String get chatInfoJoinTime => 'joined:'; + + @override + String get chatInfoCreated => 'created:'; + + @override + String get chatInfoTitle => 'Info'; + + @override + String get chatInfoMembers => 'members:'; + + @override + String get chatInfoLastSeen => 'last seen recently'; + + @override + String get chatInfoHasBots => 'has bots:'; + + @override + String get chatInfoBlockedCount => 'blocked in group:'; + + @override + String get chatInfoOfficialStatus => 'official status:'; + + @override + String get chatInfoJoined => 'joined:'; + + @override + String get chatInfoGroupCreated => 'group created:'; + + @override + String get chatInfoGroupOwner => 'group owner:'; + + @override + String get chatInfoDialogStarted => 'dialog started:'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7a1c8b4..d7780cf 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -291,4 +291,166 @@ class AppLocalizationsRu extends AppLocalizations { @override String get profileMenuSpoof => 'Подмена данных'; + + @override + String get infoTitle => 'Info'; + + @override + String get infoAccountSection => 'Аккаунт'; + + @override + String get infoServerSection => 'Сервер'; + + @override + String get infoUserSection => 'Пользователь'; + + @override + String get infoYMapSection => 'Y-Map'; + + @override + String get infoFileUploadTypes => 'запрещённые типы файлов'; + + @override + String get infoWhiteListLinks => 'безопасные ссылки'; + + @override + String get infoRegistrationTime => 'Дата регистрации:'; + + @override + String get infoCountry => 'Регион аккаунта:'; + + @override + String get infoVideoChatHistory => 'videoChatHistory'; + + @override + String get infoUpdateTime => 'Последнее обновление аватарки:'; + + @override + String get infoId => 'id аккаунта:'; + + @override + String get infoChatMarker => 'chatMarker'; + + @override + String get infoAccountRemovalEnabled => 'Мгновенное удаление аккаунта:'; + + @override + String get infoImageSize => 'image-size'; + + @override + String get infoGce => 'gce'; + + @override + String get infoGcce => 'gcce'; + + @override + String get infoMaxMsgLength => 'макс. длина сообщения:'; + + @override + String get infoQuotesEnabled => 'quotes-enabled'; + + @override + String get infoCallsEndpoint => 'calls-endpoint'; + + @override + String get infoSendLocationEnabled => 'отправка гео.:'; + + @override + String get infoLgce => 'lgce'; + + @override + String get infoWud => 'wud'; + + @override + String get infoVideoMsgEnabled => 'Кружки:'; + + @override + String get infoGrse => 'grse'; + + @override + String get infoEditTimeout => 'Можно редактировать сообщение в течении:'; + + @override + String get infoImageQuality => 'image-quality'; + + @override + String get infoUnsafeFilesAlert => 'unsafe-files-alert'; + + @override + String get infoAccountNicknameEnabled => 'account-nickname-enabled'; + + @override + String get infoMentionsEntityNamesLimit => 'макс. кол-во упоминаний:'; + + @override + String get infoReactionsEnabled => 'reactions-enabled'; + + @override + String get infoTile => 'tile'; + + @override + String get infoGeocoder => 'geocoder'; + + @override + String get infoStatic => 'static'; + + @override + String get chatInfoSubscribers => 'подписчиков:'; + + @override + String get chatInfoInvitedBy => 'Приглашён от:'; + + @override + String get chatInfoLink => 'ссылка:'; + + @override + String get chatInfoOfficial => 'оффициальный:'; + + @override + String get chatInfoComments => 'комментарии:'; + + @override + String get chatInfoAplus => 'подтверждён Роскомнадзором:'; + + @override + String get chatInfoSignAdmin => 'Подпись админов:'; + + @override + String get chatInfoLastChanged => 'последнее изменение:'; + + @override + String get chatInfoJoinTime => 'заход в канал:'; + + @override + String get chatInfoCreated => 'канал создан:'; + + @override + String get chatInfoTitle => 'Информация'; + + @override + String get chatInfoMembers => 'участников:'; + + @override + String get chatInfoLastSeen => 'был(а) недавно'; + + @override + String get chatInfoHasBots => 'Есть боты:'; + + @override + String get chatInfoBlockedCount => 'в ЧС группы:'; + + @override + String get chatInfoOfficialStatus => 'Официальный статус:'; + + @override + String get chatInfoJoined => 'Зашли в:'; + + @override + String get chatInfoGroupCreated => 'Группа создана в:'; + + @override + String get chatInfoGroupOwner => 'Создатель группы:'; + + @override + String get chatInfoDialogStarted => 'ЛС начат в:'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 0f2378c..c103607 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -104,5 +104,60 @@ } } }, - "profileMenuSpoof": "Подмена данных" + "profileMenuSpoof": "Подмена данных", + "infoTitle": "Info", + "infoAccountSection": "Аккаунт", + "infoServerSection": "Сервер", + "infoUserSection": "Пользователь", + "infoYMapSection": "Y-Map", + "infoFileUploadTypes": "запрещённые типы файлов", + "infoWhiteListLinks": "безопасные ссылки", + "infoRegistrationTime": "Дата регистрации:", + "infoCountry": "Регион аккаунта:", + "infoVideoChatHistory": "videoChatHistory", + "infoUpdateTime": "Последнее обновление аватарки:", + "infoId": "id аккаунта:", + "infoChatMarker": "chatMarker", + "infoAccountRemovalEnabled": "Мгновенное удаление аккаунта:", + "infoImageSize": "image-size", + "infoGce": "gce", + "infoGcce": "gcce", + "infoMaxMsgLength": "макс. длина сообщения:", + "infoQuotesEnabled": "quotes-enabled", + "infoCallsEndpoint": "calls-endpoint", + "infoSendLocationEnabled": "отправка гео.:", + "infoLgce": "lgce", + "infoWud": "wud", + "infoVideoMsgEnabled": "Кружки:", + "infoGrse": "grse", + "infoEditTimeout": "Можно редактировать сообщение в течении:", + "infoImageQuality": "image-quality", + "infoUnsafeFilesAlert": "unsafe-files-alert", + "infoAccountNicknameEnabled": "account-nickname-enabled", + "infoMentionsEntityNamesLimit": "макс. кол-во упоминаний:", + "infoReactionsEnabled": "reactions-enabled", + "infoTile": "tile", + "infoGeocoder": "geocoder", + "infoStatic": "static", + "chatInfoSubscribers": "подписчиков:", + "chatInfoInvitedBy": "Приглашён от:", + "chatInfoLink": "ссылка:", + "chatInfoOfficial": "оффициальный:", + "chatInfoComments": "комментарии:", + "chatInfoAplus": "подтверждён Роскомнадзором:", + "chatInfoSignAdmin": "Подпись админов:", + "chatInfoLastChanged": "последнее изменение:", + "chatInfoJoinTime": "заход в канал:", + "chatInfoCreated": "канал создан:", + "chatInfoTitle": "Информация", + "chatInfoMembers": "участников:", + "chatInfoLastSeen": "был(а) недавно", + "chatInfoHasBots": "Есть боты:", + "chatInfoBlockedCount": "в ЧС группы:", + "chatInfoOfficialStatus": "Официальный статус:", + "chatInfoLastChanged": "последнее изменение:", + "chatInfoJoined": "Зашли в:", + "chatInfoGroupCreated": "Группа создана в:", + "chatInfoGroupOwner": "Создатель группы:", + "chatInfoDialogStarted": "ЛС начат в:" } From f77f5793b5d994984ec940e5504bc03259f9efa1 Mon Sep 17 00:00:00 2001 From: klockky Date: Wed, 13 May 2026 08:26:46 -0700 Subject: [PATCH 087/323] =?UTF-8?q?fix:=20=D1=83=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D1=84=D1=80=D0=B8=D0=B7=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B2=20ChatListScreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/calls/calls_tab.dart | 8 +- .../screens/chats/chat_list_screen.dart | 103 ++++++++++++------ lib/frontend/screens/chats/chat_screen.dart | 3 +- .../screens/contacts/contacts_tab.dart | 8 +- .../screens/profile/settings_tab.dart | 24 +++- lib/frontend/widgets/message_bubble.dart | 55 ++++++---- pubspec.yaml | 1 + 7 files changed, 133 insertions(+), 69 deletions(-) diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 09edce7..4bb3a93 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show api; @@ -160,10 +161,11 @@ class _CallsTabState extends State { ), child: ClipOval( child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty - ? Image.network( - call.avatarUrl!, + ? CachedNetworkImage( + imageUrl: call.avatarUrl!, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (context, url, error) => _buildPlaceholderAvatar(cs, call.name), ) : _buildPlaceholderAvatar(cs, call.name), diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 986cbf2..d6f93ee 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:komet/backend/modules/messages.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -93,6 +94,7 @@ class _ChatListScreenState extends State final List _folderChatScrollControllers = []; final List _folderChatScrollListenerFns = []; final Set _selectedChats = {}; + final Set _inflightContactIds = {}; DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); @@ -157,14 +159,11 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); - _navPageAnimController = - AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), - value: 1.0, - )..addListener(() { - if (mounted) setState(() {}); - }); + _navPageAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + value: 1.0, + ); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), @@ -262,6 +261,7 @@ class _ChatListScreenState extends State } _isInitialLoading = false; }); + _prefetchContactsForChats(chats); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _jumpFolderPageToSelection(); @@ -315,6 +315,33 @@ class _ChatListScreenState extends State return 0; } + void _prefetchContactsForChats(List chats) { + final myId = _profile?.id; + final ids = {}; + for (final chat in chats) { + if (chat.type == 'DIALOG' && chat.id != 0) { + for (final entry in chat.participants.entries) { + if (entry.key != myId) { + ids.add(entry.key); + break; + } + } + } + final senderId = chat.lastMsgSenderId; + if (senderId != null) ids.add(senderId); + } + ids.removeWhere((id) => ContactCache.get(id) != null); + ids.removeAll(_inflightContactIds); + if (ids.isEmpty) return; + _inflightContactIds.addAll(ids); + for (final id in ids) { + messagesModule.searchContactById(id).whenComplete(() { + _inflightContactIds.remove(id); + if (mounted) setState(() {}); + }); + } + } + List _chatsForPageIndex(int pageIndex) { if (_folders.isEmpty) return _chats; if (pageIndex < 0 || pageIndex >= _folders.length) return _chats; @@ -1027,9 +1054,10 @@ class _ChatListScreenState extends State final chat = chats[index]; if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { - final secondId = chat.participants.entries.where((entry) => entry.key != _profile?.id).first.key; - // TODO: Нормальное кеширование контактов - final ss = messagesModule.searchContactById(secondId); + final secondId = chat.participants.entries + .where((entry) => entry.key != _profile?.id) + .first + .key; final name = ContactCache.get(secondId); final avatar = ContactCache.getAvatar(secondId); @@ -1044,10 +1072,6 @@ class _ChatListScreenState extends State isMuted: chat.dontDisturbUntil > 0, ); } else { - if (chat.lastMsgSenderId != null ) { - final ss = messagesModule.searchContactById(chat.lastMsgSenderId!); - } - final name = chat.lastMsgSenderId != null ? ContactCache.get(chat.lastMsgSenderId!) : null; @@ -1356,17 +1380,6 @@ class _ChatListScreenState extends State return lo + 4; } - final pageDisplayT = _effectivePageNavRowT( - inactiveWidth: inactiveWidth, - bubbleLeftForIndex: bubbleLeftForPageT, - ); - - final showChatsFab = - !_isSelectionMode && - (_navDragging || _navPageAnimController.isAnimating - ? pageDisplayT < 1.0 - : _currentNavIndex == 0); - return Stack( children: [ ClipRect( @@ -1380,8 +1393,8 @@ class _ChatListScreenState extends State child: SizedBox( width: pageW * 4, height: pageH, - child: Transform.translate( - offset: Offset(-pageDisplayT * pageW, 0), + child: AnimatedBuilder( + animation: _navPageAnimController, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1415,15 +1428,37 @@ class _ChatListScreenState extends State ), ], ), + builder: (context, child) { + final pageDisplayT = _effectivePageNavRowT( + inactiveWidth: inactiveWidth, + bubbleLeftForIndex: bubbleLeftForPageT, + ); + return Transform.translate( + offset: Offset(-pageDisplayT * pageW, 0), + child: child, + ); + }, ), ), ), ), ), _buildDockedBottomNav(cs, navInnerW, bottomInset), - ListenableBuilder( - listenable: _fabController, - builder: (context, child) { + AnimatedBuilder( + animation: Listenable.merge([ + _fabController, + _navPageAnimController, + ]), + builder: (context, _) { + final pageDisplayT = _effectivePageNavRowT( + inactiveWidth: inactiveWidth, + bubbleLeftForIndex: bubbleLeftForPageT, + ); + final showChatsFab = + !_isSelectionMode && + (_navDragging || _navPageAnimController.isAnimating + ? pageDisplayT < 1.0 + : _currentNavIndex == 0); final double val = Curves.easeOutCubic.transform( _fabController.value, ); @@ -1565,7 +1600,7 @@ class _ChatListScreenState extends State ), child: CircleAvatar( radius: 26, - backgroundImage: NetworkImage(imageUrl), + backgroundImage: CachedNetworkImageProvider(imageUrl), ), ), const SizedBox(height: 6), @@ -1717,7 +1752,7 @@ class _ChatListScreenState extends State radius: 24, backgroundColor: cs.surfaceContainerHighest, backgroundImage: imageUrl.isNotEmpty - ? NetworkImage(imageUrl) + ? CachedNetworkImageProvider(imageUrl) : null, child: imageUrl.isEmpty ? Text( @@ -2019,7 +2054,7 @@ class _ChatListScreenState extends State ), child: CircleAvatar( radius: 12, - backgroundImage: NetworkImage(imageUrl), + backgroundImage: CachedNetworkImageProvider(imageUrl), ), ), ); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 28fb06d..93009ba 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -270,7 +271,7 @@ class _ChatScreenState extends State if (widget.imageUrl.isNotEmpty) CircleAvatar( radius: 18, - backgroundImage: NetworkImage(widget.imageUrl), + backgroundImage: CachedNetworkImageProvider(widget.imageUrl), ) else CircleAvatar( diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 3d86580..a2d00d6 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; @@ -84,10 +85,11 @@ class _ContactsTabState extends State { ), child: ClipOval( child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty - ? Image.network( - contact.baseUrl!, + ? CachedNetworkImage( + imageUrl: contact.baseUrl!, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (context, url, error) => _buildPlaceholderAvatar(cs, nameToDisplay), ) : _buildPlaceholderAvatar(cs, nameToDisplay), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 2389838..783996c 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -320,10 +321,11 @@ class _SettingsTabState extends State { ), child: ClipOval( child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty - ? Image.network( - _profile!.baseUrl!, + ? CachedNetworkImage( + imageUrl: _profile!.baseUrl!, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (context, url, error) => _buildPlaceholderAvatar(cs, name), ) : _buildPlaceholderAvatar(cs, name), @@ -501,7 +503,21 @@ class _PhoneSpoilerState extends State<_PhoneSpoiler> _controller = AnimationController( vsync: this, duration: const Duration(seconds: 2), - )..repeat(); + ); + if (!widget.isVisible) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant _PhoneSpoiler oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isVisible == oldWidget.isVisible) return; + if (widget.isVisible) { + _controller.stop(); + } else if (!_controller.isAnimating) { + _controller.repeat(); + } } @override diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 74c4ed9..9042070 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/contacts.dart'; @@ -310,7 +311,7 @@ class MessageBubble extends StatelessWidget { && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) CircleAvatar( radius: 15, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else if (displaySender != null && !isMe && chatType != "DIALOG" @@ -454,7 +455,7 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else @@ -706,7 +707,7 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else @@ -779,7 +780,7 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else @@ -845,7 +846,7 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else @@ -911,12 +912,13 @@ class MessageBubble extends StatelessWidget { child: Stack( children: [ if (imageUrl.isNotEmpty) - Image.network( - imageUrl, + CachedNetworkImage( + imageUrl: imageUrl, width: constrainedWidth, height: constrainedHeight, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildPhotoPlaceholder( + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => _buildPhotoPlaceholder( ctx, constrainedWidth, constrainedHeight, @@ -1015,12 +1017,13 @@ class MessageBubble extends StatelessWidget { child: Stack( children: [ if (imageUrl.isNotEmpty) - Image.network( - imageUrl, + CachedNetworkImage( + imageUrl: imageUrl, fit: BoxFit.cover, width: double.infinity, height: double.infinity, - errorBuilder: (_, __, ___) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => _buildPhotoPlaceholder(ctx, 100, 100), ) else @@ -1049,12 +1052,13 @@ class MessageBubble extends StatelessWidget { child: Stack( children: [ if (imageUrl.isNotEmpty) - Image.network( - imageUrl, + CachedNetworkImage( + imageUrl: imageUrl, fit: BoxFit.cover, width: double.infinity, height: double.infinity, - errorBuilder: (_, __, ___) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => _buildPhotoPlaceholder(ctx, 100, 100), ) else @@ -1276,12 +1280,13 @@ class MessageBubble extends StatelessWidget { child: Stack( children: [ if (preview.isNotEmpty) - Image.network( - preview, + CachedNetworkImage( + imageUrl: preview, width: 150, height: 150, fit: BoxFit.contain, - errorBuilder: (_, __, ___) => + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => _buildPhotoPlaceholder(ctx, 150, 150), ) else @@ -1333,10 +1338,11 @@ class MessageBubble extends StatelessWidget { child: photoUrl != null && photoUrl.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(24), - child: Image.network( - photoUrl, + child: CachedNetworkImage( + imageUrl: photoUrl, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Icon( + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => Icon( Symbols.person, color: isMe ? Colors.white : cs.primary, size: 24, @@ -1435,7 +1441,7 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: NetworkImage(senderAvatar), + backgroundImage: CachedNetworkImageProvider(senderAvatar), backgroundColor: cs.primaryContainer, ) else @@ -1478,10 +1484,11 @@ class MessageBubble extends StatelessWidget { child: photoUrl != null && photoUrl.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(24), - child: Image.network( - photoUrl, + child: CachedNetworkImage( + imageUrl: photoUrl, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Icon( + fadeInDuration: const Duration(milliseconds: 120), + errorWidget: (_, __, ___) => Icon( Symbols.person, color: isMe ? Colors.white : cs.primary, size: 24, diff --git a/pubspec.yaml b/pubspec.yaml index f681c96..ca642df 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: shared_preferences: ^2.5.4 package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 + cached_network_image: ^3.4.1 dev_dependencies: flutter_test: From 2991dd45009b666286c5f24f12f78fe390886993 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 13 May 2026 23:57:16 +0700 Subject: [PATCH 088/323] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 79 +++++- lib/core/storage/app_database.dart | 7 +- .../screens/profile/edit_profile_screen.dart | 252 ++++++++++++++++++ lib/frontend/screens/profile/info_screen.dart | 32 +-- .../screens/profile/settings_tab.dart | 19 +- lib/l10n/app_en.arb | 7 +- lib/l10n/app_localizations.dart | 30 +++ lib/l10n/app_localizations_en.dart | 15 ++ lib/l10n/app_localizations_ru.dart | 15 ++ lib/l10n/app_ru.arb | 7 +- lib/main.dart | 7 + 11 files changed, 445 insertions(+), 25 deletions(-) create mode 100644 lib/frontend/screens/profile/edit_profile_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 8d6f6b8..288a097 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/material.dart' show Locale; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -431,6 +432,82 @@ class AccountModule { return config; } + Future updateProfileName(String firstName, String? lastName) async { + _ensureOnline(); + final payload = { + 'firstName': firstName, + }; + if (lastName != null) payload['lastName'] = lastName; + final packet = await _api.sendRequest(Opcode.profile, payload); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + + Future updateProfileAvatar(String photoToken, String avatarType) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.profile, { + 'photoToken': photoToken, + 'avatarType': avatarType, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + + Future getAvatarUploadUrl() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.photoUpload, { + 'count': 1, + 'profile': true, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final url = data['url'] as String?; + if (url == null) throw Exception('No url in response'); + return url; + } + + Future removeProfilePhoto(int photoId) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.removeContactPhoto, { + 'photoId': photoId, + }); + if (packet.isError) { + throw Exception(packet.payload?.toString() ?? 'Server error'); + } + final data = packet.payload as Map?; + if (data == null) throw Exception('Empty response'); + final profile = data['profile'] as Map?; + if (profile == null) throw Exception('No profile in response'); + final contact = profile['contact'] as Map?; + if (contact == null) throw Exception('No contact in response'); + final newProfile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(newProfile, isActive: true); + return newProfile; + } + // 2FA Creation (when not set) Future create2faTrack() async { _ensureOnline(); @@ -931,7 +1008,7 @@ class AccountModule { throw Exception('login: отсутствует profile.contact в ответе'); } final profile = ProfileData.fromServerMap(contact.cast()); - await AppDatabase.saveProfile(profile); + await AppDatabase.saveProfile(profile, isActive: true); await AppDatabase.setActiveAccount(profile.id); await _saveSyncState(data, serverTime, profile.id); diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 7283d9b..24f02d9 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -98,7 +98,7 @@ class ProfileData { ); } - Map toDbRow() => { + Map toDbRow({bool isActive = false}) => { 'id': id, 'first_name': firstName, 'last_name': lastName, @@ -109,6 +109,7 @@ class ProfileData { 'country': country, 'account_status': accountStatus, 'update_time': updateTime, + 'is_active': isActive ? 1 : 0, 'profile_options': profileOptions?.join(','), }; } @@ -280,11 +281,11 @@ class AppDatabase { ) '''; - static Future saveProfile(ProfileData profile) async { + static Future saveProfile(ProfileData profile, {bool isActive = true}) async { final db = await _instance; await db.insert( 'profile', - profile.toDbRow(), + profile.toDbRow(isActive: isActive), conflictAlgorithm: ConflictAlgorithm.replace, ); } diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart new file mode 100644 index 0000000..11a94c5 --- /dev/null +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -0,0 +1,252 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart' show accountModule, KometApp; +import '../../widgets/custom_notification.dart'; + +class EditProfileScreen extends StatefulWidget { + const EditProfileScreen({super.key}); + + @override + State createState() => _EditProfileScreenState(); +} + +class _EditProfileScreenState extends State { + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + bool _isLoading = true; + bool _isSaving = false; + String? _avatarUrl; + int? _photoId; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + Future _loadProfile() async { + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + if (profile != null) { + _firstNameController.text = profile.firstName; + _lastNameController.text = profile.lastName ?? ''; + _avatarUrl = profile.baseUrl; + _photoId = profile.photoId; + setState(() => _isLoading = false); + } else { + setState(() => _isLoading = false); + } + } + + Future _saveName() async { + if (_isSaving) return; + final firstName = _firstNameController.text.trim(); + if (firstName.isEmpty) { + if (mounted) showCustomNotification(context, 'Имя не может быть пустым'); + return; + } + setState(() => _isSaving = true); + try { + final newProfile = await accountModule.updateProfileName( + firstName, + _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), + ); + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + KometApp.stateOf(context)?.notifyProfileUpdate(); + if (mounted) { + showCustomNotification(context, 'Имя сохранено'); + setState(() => _isSaving = false); + } + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); + } + } + + Future _changeAvatar() async { + if (_isSaving) return; + try { + final uploadUrl = await accountModule.getAvatarUploadUrl(); + if (!mounted) return; + showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)'); + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } + } + + Future _removeAvatar() async { + if (_isSaving || _photoId == null) return; + setState(() => _isSaving = true); + try { + final newProfile = await accountModule.removeProfilePhoto(_photoId!); + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + KometApp.stateOf(context)?.notifyProfileUpdate(); + if (mounted) { + showCustomNotification(context, 'Фото удалено'); + setState(() => _isSaving = false); + } + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + l10n?.editProfileTitle ?? 'Edit Profile', + style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), + ), + actions: [ + TextButton( + onPressed: _isLoading || _isSaving ? null : _saveName, + child: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + l10n?.editProfileSave ?? 'Save', + style: TextStyle(color: cs.primary, fontWeight: FontWeight.w600), + ), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: Stack( + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: ClipOval( + child: _avatarUrl != null && _avatarUrl!.isNotEmpty + ? Image.network(_avatarUrl!, fit: BoxFit.cover) + : Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + _firstNameController.text.isNotEmpty + ? _firstNameController.text[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon(Symbols.camera_alt, color: cs.onPrimary, size: 20), + onPressed: _changeAvatar, + ), + ), + ), + ], + ), + ), + if (_photoId != null) ...[ + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: _removeAvatar, + child: Text( + l10n?.editProfileRemovePhoto ?? 'Remove photo', + style: TextStyle(color: cs.error), + ), + ), + ), + ], + const SizedBox(height: 24), + _buildTextField( + l10n?.editProfileFirstName ?? 'First name', + _firstNameController, + cs, + enabled: !_isSaving, + ), + const SizedBox(height: 12), + _buildTextField( + l10n?.editProfileLastName ?? 'Last name', + _lastNameController, + cs, + enabled: !_isSaving, + ), + const SizedBox(height: 120), + ], + ), + ); + } + + Widget _buildTextField(String label, TextEditingController controller, ColorScheme cs, {bool enabled = true}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ), + TextField( + controller: controller, + enabled: enabled, + decoration: InputDecoration( + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index 715ccd1..58febca 100644 --- a/lib/frontend/screens/profile/info_screen.dart +++ b/lib/frontend/screens/profile/info_screen.dart @@ -115,11 +115,11 @@ class _InfoScreenState extends State { padding: const EdgeInsets.all(16), children: [ _buildSectionTitle(l10n.infoAccountSection, cs), - ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key]), cs)), + ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs)), const SizedBox(height: 16), _buildSectionTitle(l10n.infoServerSection, cs), - ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key]), cs)), + ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key], e.key), cs)), const SizedBox(height: 8), _buildSectionTitle(l10n.infoYMapSection, cs), @@ -240,28 +240,17 @@ class _InfoScreenState extends State { ); } - String _formatValue(dynamic value) { + String _formatValue(dynamic value, String key) { if (value == null) return '-'; if (value is Map && value.containsKey('chatMarker')) { final ts = value['chatMarker'] as int?; - if (ts != null) { - final dt = DateTime.fromMillisecondsSinceEpoch(ts); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; - } - return '-'; + return ts != null ? _formatTs(ts) : '-'; } - if (value is int && value > 1000000000000) { - final dt = DateTime.fromMillisecondsSinceEpoch(value); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; - } - if (value is int && value > 86400) { + if (value is int && value > 1000000000000) return _formatTs(value); + if (key == 'edit-timeout' && value is int && value > 0) { final weeks = value ~/ 604800; final days = (value % 604800) ~/ 86400; - if (weeks > 0) { - return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); - } + if (weeks > 0) return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); final h = value ~/ 3600; final m = (value % 3600) ~/ 60; if (h > 0) return '${h}h ${m}m'; @@ -270,6 +259,13 @@ class _InfoScreenState extends State { return value.toString(); } + String _formatTs(int ts) { + if (ts < 1000000000000) return ts.toString(); + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + } + String _w(int n) { final m = n % 10; if (m == 1 && n != 11) return 'нед'; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index fd445cb..f38811b 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -6,9 +6,11 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; import '../../../l10n/app_localizations.dart'; +import '../../../main.dart'; import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; +import 'edit_profile_screen.dart'; import 'info_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -27,17 +29,25 @@ class _SettingsTabState extends State { bool _debugMenuVisible = false; int _versionSecretTapCount = 0; Timer? _versionSecretTapResetTimer; + StreamSubscription? _profileUpdateSub; @override void initState() { super.initState(); _loadProfile(); _loadAppVersion(); + final appState = KometApp.stateOf(context); + if (appState != null) { + _profileUpdateSub = appState.profileUpdateStream.listen((_) { + if (mounted) _loadProfile(); + }); + } } @override void dispose() { _versionSecretTapResetTimer?.cancel(); + _profileUpdateSub?.cancel(); super.dispose(); } @@ -317,7 +327,14 @@ child: _buildSection( size: 22, weight: 400, ), - onPressed: () {}, + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const EditProfileScreen(), + ), + ); + }, ), ], ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 174459b..c7b227b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -159,5 +159,10 @@ "chatInfoJoined": "joined:", "chatInfoGroupCreated": "group created:", "chatInfoGroupOwner": "group owner:", - "chatInfoDialogStarted": "dialog started:" + "chatInfoDialogStarted": "dialog started:", + "editProfileTitle": "Edit Profile", + "editProfileSave": "Save", + "editProfileFirstName": "First name", + "editProfileLastName": "Last name", + "editProfileRemovePhoto": "Remove photo" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 15fbdfb..ce14093 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -955,6 +955,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'dialog started:'** String get chatInfoDialogStarted; + + /// No description provided for @editProfileTitle. + /// + /// In en, this message translates to: + /// **'Edit Profile'** + String get editProfileTitle; + + /// No description provided for @editProfileSave. + /// + /// In en, this message translates to: + /// **'Save'** + String get editProfileSave; + + /// No description provided for @editProfileFirstName. + /// + /// In en, this message translates to: + /// **'First name'** + String get editProfileFirstName; + + /// No description provided for @editProfileLastName. + /// + /// In en, this message translates to: + /// **'Last name'** + String get editProfileLastName; + + /// No description provided for @editProfileRemovePhoto. + /// + /// In en, this message translates to: + /// **'Remove photo'** + String get editProfileRemovePhoto; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0320c05..6f4c3c6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -451,4 +451,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatInfoDialogStarted => 'dialog started:'; + + @override + String get editProfileTitle => 'Edit Profile'; + + @override + String get editProfileSave => 'Save'; + + @override + String get editProfileFirstName => 'First name'; + + @override + String get editProfileLastName => 'Last name'; + + @override + String get editProfileRemovePhoto => 'Remove photo'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d7780cf..999ea90 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -453,4 +453,19 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chatInfoDialogStarted => 'ЛС начат в:'; + + @override + String get editProfileTitle => 'Редактирование профиля'; + + @override + String get editProfileSave => 'Сохранить'; + + @override + String get editProfileFirstName => 'Имя'; + + @override + String get editProfileLastName => 'Фамилия'; + + @override + String get editProfileRemovePhoto => 'Удалить фото'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c103607..3d134a9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -159,5 +159,10 @@ "chatInfoJoined": "Зашли в:", "chatInfoGroupCreated": "Группа создана в:", "chatInfoGroupOwner": "Создатель группы:", - "chatInfoDialogStarted": "ЛС начат в:" + "chatInfoDialogStarted": "ЛС начат в:", + "editProfileTitle": "Редактирование профиля", + "editProfileSave": "Сохранить", + "editProfileFirstName": "Имя", + "editProfileLastName": "Фамилия", + "editProfileRemovePhoto": "Удалить фото" } diff --git a/lib/main.dart b/lib/main.dart index 5c62627..1fd803a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -77,6 +77,8 @@ class KometAppState extends State { late final ValueNotifier fpsOverlayEnabled = ValueNotifier( widget.initialFpsOverlay, ); + final _profileUpdateController = StreamController.broadcast(); + Stream get profileUpdateStream => _profileUpdateController.stream; @override void initState() { @@ -123,6 +125,7 @@ class KometAppState extends State { @override void dispose() { _sessionExpiredSub?.cancel(); + _profileUpdateController.close(); fpsOverlayEnabled.dispose(); super.dispose(); } @@ -147,6 +150,10 @@ class KometAppState extends State { } } + void notifyProfileUpdate() { + _profileUpdateController.add(null); + } + ColorScheme _adjustDarkScheme(ColorScheme base) { return base.copyWith( surface: Color.alphaBlend( From a51bae555b1bde6e8c57dcdbcc8226bdd8387a2c Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 08:43:46 +0700 Subject: [PATCH 089/323] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=D0=BE=20=D1=87=D0=B0=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B8=20=D0=B2=20=D0=BD=D0=B5=D0=BC=20=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D1=81=D1=82=D0=B8=D0=BA=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/attachment.dart | 6 ++-- pubspec.lock | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 825e348..fd7d35f 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -304,9 +304,9 @@ class StickerAttachment extends MessageAttachment { return StickerAttachment( previewData: previewStr, - baseUrl: map['baseUrl'] as String?, - stickerId: map['stickerId'] as String?, - stickerPackId: map['stickerPackId'] as String?, + baseUrl: map['baseUrl']?.toString(), + stickerId: map['stickerId']?.toString(), + stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(), width: map['width'] as int?, height: map['height'] as int?, ); diff --git a/pubspec.lock b/pubspec.lock index 1f7d572..0cbc9e9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -129,11 +153,27 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -373,6 +413,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_info_plus: dependency: "direct main" description: @@ -469,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: @@ -658,6 +714,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: From e1846d86611dd6239802a064396619597f31ad8b Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 12:34:07 +0700 Subject: [PATCH 090/323] =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B3=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 57 +++ lib/core/protocol/opcode_map.dart | 6 + lib/core/transport/dispatcher.dart | 6 +- lib/frontend/widgets/custom_notification.dart | 4 +- lib/frontend/widgets/message_bubble.dart | 457 ++++++++++++++---- lib/models/attachment.dart | 19 +- 6 files changed, 440 insertions(+), 109 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 255ff2e..150e5ca 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -23,6 +23,34 @@ class ContactCache { static String? getAvatar(int id) => _avatarCache[id]; } +class TranscriptionResult { + final int status; + final String? text; + final String? messageId; + final int? chatId; + final int? mediaId; + + TranscriptionResult({ + required this.status, + this.text, + this.messageId, + this.chatId, + this.mediaId, + }); +} + +class TranscriptionCache { + static final Map _cache = {}; + + static void put(String messageId, TranscriptionResult result) { + _cache[messageId] = result; + } + + static TranscriptionResult? get(String messageId) => _cache[messageId]; + + static bool has(String messageId) => _cache.containsKey(messageId); +} + class CachedMessage { final String id; final int accountId; @@ -247,6 +275,35 @@ class MessagesModule { await _api.sendRequest(Opcode.msgSend, payload); } + Future requestTranscription( + int chatId, + int messageId, + int mediaId, + ) async { + final payload = { + 'chatId': chatId, + 'messageId': messageId, + 'mediaId': mediaId, + }; + + final response = await _api.sendRequest(Opcode.audioTranscription, payload); + if (!response.isOk) return TranscriptionResult(status: -1); + + final data = response.payload; + if (data is! Map) return TranscriptionResult(status: -1); + + final transcriptionStatus = data['transcriptionStatus'] as int? ?? -1; + if (transcriptionStatus == 1) { + final text = data['transcription'] as String? ?? ''; + if (text.isEmpty) { + return TranscriptionResult(status: 1, text: 'не удалось распознать текст'); + } + return TranscriptionResult(status: 1, text: text); + } + + return TranscriptionResult(status: transcriptionStatus); + } + Future downloadPhoto(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index edc40e5..8f0f5c9 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -170,6 +170,10 @@ abstract class Opcode { static const int notifBanners = 292; // Баннеры static const int notifFolders = 277; // Обновление папок + // ── Transcription ─────────────────────────────────────────────────── + static const int audioTranscription = 202; // Запрос транскрибации аудио + static const int transcriptionResult = 293; // Результат транскрибации (push) + // ── Misc ─────────────────────────────────────────────────────────── static const int okToken = 158; // OK-токен static const int webAppInitData = 160; // Данные WebApp @@ -332,6 +336,8 @@ abstract class Opcode { notifProfile: 'NOTIF_PROFILE', notifBanners: 'NOTIF_BANNERS', notifFolders: 'NOTIF_FOLDERS', + audioTranscription: 'AUDIO_TRANSCRIPTION', + transcriptionResult: 'TRANSCRIPTION_RESULT', okToken: 'OK_TOKEN', webAppInitData: 'WEB_APP_INIT_DATA', complain: 'COMPLAIN', diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index a7ff78e..a97b3c8 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -53,8 +53,12 @@ class PacketDispatcher { if (packet.cmd == CmdType.ok || packet.cmd == CmdType.error || packet.cmd == CmdType.notFound) { + final payloadStr = packet.payload.toString(); + final displayPayload = packet.opcode == Opcode.login && payloadStr.length > 50 + ? '${payloadStr.substring(0, 50)}...' + : payloadStr; logger.i( - '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}', + '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: $displayPayload}', ); final completer = _pendingRequests.remove(packet.seq); diff --git a/lib/frontend/widgets/custom_notification.dart b/lib/frontend/widgets/custom_notification.dart index 5bdb22b..f30fcec 100644 --- a/lib/frontend/widgets/custom_notification.dart +++ b/lib/frontend/widgets/custom_notification.dart @@ -10,7 +10,7 @@ void showCustomNotificationOnOverlay(OverlayState overlay, String message) { builder: (context) => CustomNotification(message: message), ); overlay.insert(entry); - Future.delayed(const Duration(milliseconds: 1900), () { + Future.delayed(const Duration(milliseconds: 2600), () { entry.remove(); }); } @@ -38,7 +38,7 @@ class _CustomNotificationState extends State ); _opacity = Tween(begin: 0.0, end: 1.0).animate(_controller); _controller.forward(); - Future.delayed(const Duration(milliseconds: 1600), () { + Future.delayed(const Duration(milliseconds: 2300), () { if (mounted) _controller.reverse(); }); } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 03fc940..9e61d51 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -112,19 +112,22 @@ class MessageBubble extends StatelessWidget { return MessageType.text; } - // скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие BorderRadius get _borderRadius { final topRadius = Radius.circular(bubbleBorderRadius); - final bottomRadius = Radius.circular(bubbleBorderRadius); final smallRadius = const Radius.circular(4); + final cornerTL = isMe ? smallRadius : topRadius; + final cornerTR = isMe ? topRadius : smallRadius; + final cornerBL = isMe ? smallRadius : topRadius; + final cornerBR = isMe ? topRadius : smallRadius; + if (_hasPhotoWithCaption && (shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle || shape == BubbleShape.singleBottom)) { return BorderRadius.only( topLeft: topRadius, - topRight: isMe ? topRadius : topRadius, + topRight: topRadius, bottomLeft: smallRadius, bottomRight: smallRadius, ); @@ -136,16 +139,16 @@ class MessageBubble extends StatelessWidget { return BorderRadius.only( topLeft: smallRadius, topRight: smallRadius, - bottomLeft: isMe ? smallRadius : smallRadius, - bottomRight: isMe ? smallRadius : bottomRadius, + bottomLeft: smallRadius, + bottomRight: isMe ? smallRadius : topRadius, ); } switch (shape) { case BubbleShape.singleTop: return BorderRadius.only( - topLeft: isMe ? topRadius : smallRadius, - topRight: isMe ? smallRadius : topRadius, + topLeft: cornerTL, + topRight: cornerTR, bottomLeft: smallRadius, bottomRight: smallRadius, ); @@ -153,22 +156,22 @@ class MessageBubble extends StatelessWidget { return BorderRadius.only( topLeft: smallRadius, topRight: smallRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: isMe ? smallRadius : topRadius, + bottomLeft: cornerBL, + bottomRight: cornerBR, ); case BubbleShape.singleMiddle: return BorderRadius.only( - topLeft: topRadius, - topRight: topRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: isMe ? smallRadius : topRadius, + topLeft: cornerTL, + topRight: cornerTR, + bottomLeft: cornerBL, + bottomRight: cornerBR, ); case BubbleShape.groupedMiddle: return BorderRadius.only( - topLeft: isMe ? topRadius : smallRadius, - topRight: isMe ? smallRadius : smallRadius, - bottomLeft: isMe ? topRadius : smallRadius, - bottomRight: isMe ? smallRadius : smallRadius, + topLeft: cornerTL, + topRight: smallRadius, + bottomLeft: cornerBL, + bottomRight: smallRadius, ); } } @@ -273,13 +276,13 @@ class MessageBubble extends StatelessWidget { case MessageType.voice: switch (shape) { case BubbleShape.groupedMiddle: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); case BubbleShape.singleTop: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); case BubbleShape.singleBottom: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); case BubbleShape.singleMiddle: - return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); } } return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); @@ -1550,23 +1553,47 @@ class MessageBubble extends StatelessWidget { final textColor = isMe ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - final payload = message.payload; - final voice = payload?['voice'] as Map?; - final duration = voice?['duration'] as int? ?? 0; - final url = voice?['url']?.toString() ?? ''; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _VoiceMessageBubble( - duration: duration, - url: url, - textColor: textColor, - isMe: isMe, - ), - const SizedBox(height: 6), - _buildMeta(context), - ], + int duration = 0; + String url = ''; + String? waveData; + int? audioId; + + final attaches = message.attachments; + if (attaches != null && attaches.isNotEmpty) { + for (final a in attaches) { + if (a is AudioAttachment) { + duration = ((a.duration ?? 0) / 1000).round(); + url = a.fileUrl ?? a.baseUrl ?? ''; + waveData = a.waveform; + audioId = a.audioId; + break; + } + } + } + + if (duration == 0 && url.isEmpty) { + final payload = message.payload; + final voice = payload?['voice'] as Map?; + duration = ((voice?['duration'] as int? ?? 0) / 1000).round(); + url = voice?['url']?.toString() ?? ''; + } + + final cachedTranscription = TranscriptionCache.get(message.id); + + return _VoiceMessageBubble( + duration: duration, + url: url, + textColor: textColor, + isMe: isMe, + status: message.status, + time: message.time, + cs: cs, + waveData: waveData, + chatId: message.chatId, + messageId: message.id, + audioId: audioId, + preloadedText: cachedTranscription?.text, ); } @@ -1666,12 +1693,28 @@ class _VoiceMessageBubble extends StatefulWidget { final String url; final Color textColor; final bool isMe; + final String? status; + final int time; + final ColorScheme cs; + final String? waveData; + final int chatId; + final String messageId; + final int? audioId; + final String? preloadedText; const _VoiceMessageBubble({ required this.duration, required this.url, required this.textColor, required this.isMe, + this.status, + required this.time, + required this.cs, + this.waveData, + required this.chatId, + required this.messageId, + this.audioId, + this.preloadedText, }); @override @@ -1681,97 +1724,305 @@ class _VoiceMessageBubble extends StatefulWidget { class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { bool _isPlaying = false; double _progress = 0.0; + bool _transcriptionVisible = false; + String? _transcriptionText; + bool _transcriptionLoading = false; @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final isDark = cs.brightness == Brightness.dark; + void initState() { + super.initState(); + if (widget.preloadedText != null) { + _transcriptionText = widget.preloadedText; + _transcriptionVisible = true; + } + } - return Container( - width: 220, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( + String _formatDuration(int seconds) { + final min = seconds ~/ 60; + final sec = seconds % 60; + return '$min:${sec.toString().padLeft(2, '0')}'; + } + + String _formatTime(int timestamp) { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + Widget _buildStatusIcon() { + final status = widget.status; + IconData icon; + Color color; + + if (status == null || status == 'sending' || status == 'pending') { + icon = Symbols.check; + color = Colors.white54; + } else { + switch (status) { + case 'sent': + icon = Symbols.check; + color = Colors.white54; + case 'delivered': + icon = Symbols.done_all; + color = Colors.white54; + case 'read': + icon = Symbols.done_all; + color = const Color(0xFF34C759); + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = Colors.white54; + } + } + + return Icon(icon, size: 14, color: color); + } + + Widget build(BuildContext context) { + final isDark = widget.cs.brightness == Brightness.dark; + final waveInactiveColor = widget.isMe + ? Colors.white.withValues(alpha: 0.35) + : (isDark + ? widget.cs.surfaceContainerHighest + : const Color(0xFFD1D1D6)); + final waveActiveColor = widget.isMe + ? Colors.white.withValues(alpha: 0.7) + : widget.cs.primary; + + return SizedBox( + width: 240, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: _togglePlay, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: widget.isMe - ? Colors.white.withValues(alpha: 0.2) - : cs.primaryContainer, - shape: BoxShape.circle, + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + onTap: _togglePlay, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.2) + : widget.cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + _isPlaying ? Symbols.pause : Symbols.play_arrow, + color: widget.isMe ? Colors.white : widget.cs.primary, + size: 18, + ), + ), ), - child: Icon( - _isPlaying ? Symbols.pause : Symbols.play_arrow, - color: widget.isMe ? Colors.white : cs.primary, - size: 20, - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - Container( - height: 24, - decoration: BoxDecoration( - color: widget.isMe - ? Colors.white.withValues(alpha: 0.2) - : (isDark - ? cs.surfaceContainerHighest - : const Color(0xFFD1D1D6)), - borderRadius: BorderRadius.circular(2), - ), - ), - FractionallySizedBox( - widthFactor: _progress.clamp(0.0, 1.0), + const SizedBox(width: 10), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + onTapDown: (details) { + setState(() { + _progress = (details.localPosition.dx / + constraints.maxWidth) + .clamp(0.0, 1.0); + }); + }, + onHorizontalDragUpdate: (details) { + setState(() { + _progress = (details.localPosition.dx / + constraints.maxWidth) + .clamp(0.0, 1.0); + }); + }, child: Container( - height: 24, + height: 4, decoration: BoxDecoration( - color: widget.isMe - ? Colors.white.withValues(alpha: 0.5) - : cs.primary, + color: waveInactiveColor, borderRadius: BorderRadius.circular(2), ), - ), - ), - SizedBox( - height: 24, - child: Center( - child: Text( - _formatDuration(widget.duration), - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.8), - fontSize: 12, - fontWeight: FontWeight.w500, + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: _progress.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: waveActiveColor, + borderRadius: BorderRadius.circular(2), + ), ), ), ), - ), - ], + ); + }, ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: _requestTranscription, + child: SizedBox( + width: 20, + height: 32, + child: Center( + child: _transcriptionLoading + ? SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: widget.textColor.withValues(alpha: 0.6), + ), + ) + : Text( + 'Т', + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatDuration(widget.duration), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.7), + fontSize: 11, + ), + ), + const SizedBox(width: 8), + Expanded( + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + alignment: Alignment.topLeft, + child: _transcriptionVisible + ? Text( + _transcriptionText ?? '', + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.8), + fontSize: 12, + height: 1.3, + ), + maxLines: 10, + overflow: TextOverflow.ellipsis, + ) + : const SizedBox.shrink(), + ), + ), + if (!_transcriptionVisible) ...[ + Text( + _formatTime(widget.time), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + if (widget.isMe) ...[ + const SizedBox(width: 2), + _buildStatusIcon(), + ], + ], + ], + ), + if (_transcriptionVisible) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + _formatTime(widget.time), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + if (widget.isMe) ...[ + const SizedBox(width: 2), + _buildStatusIcon(), + ], ], ), - ), + ], ], ), ); } + Widget _buildProgressBar(Color inactive, Color active) { + return Container( + height: 4, + decoration: BoxDecoration( + color: inactive, + borderRadius: BorderRadius.circular(2), + ), + ); + } + void _togglePlay() { setState(() { _isPlaying = !_isPlaying; }); } - String _formatDuration(int seconds) { - final min = seconds ~/ 60; - final sec = seconds % 60; - return '$min:${sec.toString().padLeft(2, '0')}'; + Future _requestTranscription() async { + if (widget.audioId == null) return; + + if (_transcriptionVisible && _transcriptionText != null) { + setState(() { + _transcriptionVisible = false; + }); + return; + } + + if (TranscriptionCache.has(widget.messageId)) { + final cached = TranscriptionCache.get(widget.messageId)!; + setState(() { + _transcriptionText = cached.text ?? 'не удалось распознать текст'; + _transcriptionVisible = true; + }); + return; + } + + setState(() { + _transcriptionLoading = true; + }); + + try { + final result = await messagesModule.requestTranscription( + widget.chatId, + int.tryParse(widget.messageId) ?? 0, + widget.audioId!, + ); + + TranscriptionCache.put(widget.messageId, result); + + setState(() { + _transcriptionLoading = false; + if (result.status == 1) { + _transcriptionText = (result.text == null || result.text!.isEmpty) + ? 'не удалось распознать текст' + : result.text; + _transcriptionVisible = true; + } else if (result.status == 0) { + _transcriptionText = 'транскрибация...'; + _transcriptionVisible = true; + } + }); + } catch (e) { + setState(() { + _transcriptionLoading = false; + _transcriptionText = 'ошибка транскрибации'; + _transcriptionVisible = true; + }); + } } } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index fd7d35f..8e137d2 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -198,14 +198,27 @@ class AudioAttachment extends MessageAttachment { } catch (_) {} } + String? waveStr; + final waveRaw = map['wave']; + if (waveRaw is String) { + waveStr = waveRaw; + } else if (waveRaw is List) { + try { + final bytes = List.from(waveRaw); + final base64 = String.fromCharCodes(bytes); + waveStr = 'data:image/webp;base64,$base64'; + } catch (_) {} + } + return AudioAttachment( previewData: previewStr, - baseUrl: map['baseUrl'] as String?, + baseUrl: map['baseUrl']?.toString(), + fileUrl: map['url']?.toString(), audioId: map['audioId'] as int?, - audioToken: map['audioToken'] as String?, + audioToken: map['token']?.toString(), duration: map['duration'] as int?, size: map['size'] as int?, - waveform: map['waveform'] as String?, + waveform: waveStr, ); } From d541e9df9dcf52ceed7563a59ffd40102285aade Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 14 May 2026 13:12:41 +0300 Subject: [PATCH 091/323] =?UTF-8?q?fix(transport):=20Zstd-=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BF=D0=B0=D0=BA=D0=BE=D0=B2=D0=BA=D0=B0,=20=D0=B3?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=B0=20PacketReceiver,=20fix=20=D0=B8=D0=BC?= =?UTF-8?q?=D1=91=D0=BD=20=D0=B8=20=D1=81=D1=82=D0=B8=D0=BA=D0=B5=D1=80?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 10 ++++- lib/backend/modules/chats.dart | 1 - lib/backend/modules/contacts.dart | 54 ++++++++++++++++++++--- lib/core/protocol/packet.dart | 55 +++++++++++++++++------- lib/core/transport/receiver.dart | 21 ++++----- lib/frontend/widgets/message_bubble.dart | 8 ++-- lib/main.dart | 5 +++ lib/models/attachment.dart | 6 +-- pubspec.lock | 16 +++++++ pubspec.yaml | 1 + 10 files changed, 137 insertions(+), 40 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 495b56a..1fdd71f 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -266,7 +266,15 @@ class Api { } Future _onDataReceived(Uint8List data) async { - await for (final packet in _receiver.feed(data)) { + final rawPackets = _receiver.feed(data); + for (final raw in rawPackets) { + final Packet packet; + try { + packet = await unpackPacket(raw); + } catch (e) { + logger.e('PacketReceiver: ошибка распаковки: $e'); + continue; + } if (packet.isError && packet.payload is Map && (packet.payload['message'] == 'FAIL_LOGIN_TOKEN' || diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index f47aa70..2308163 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -160,7 +160,6 @@ class ChatsModule { static Future> getChats(int accountId) async { try { final rows = await AppDatabase.loadChats(accountId); - return rows.map(CachedChat.fromDbRow).toList(); } catch (e) { logger.e("Ошибка при получении чатов: $e"); diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 71e4926..b4f103b 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -1,4 +1,5 @@ import '../../core/storage/app_database.dart'; +import 'messages.dart'; class CachedContact { final int id; @@ -44,22 +45,65 @@ class ContactsModule { final contacts = data['contacts']; if (contacts is! List || contacts.isEmpty) return; - final rows = contacts - .whereType() - .map((c) => _parseContact(c.cast(), accountId)) - .whereType>() - .toList(); + final rows = >[]; + for (final raw in contacts.whereType()) { + final contact = raw.cast(); + final row = _parseContact(contact, accountId); + if (row != null) rows.add(row); + _primeContactCache(contact); + } if (rows.isNotEmpty) { await AppDatabase.saveContacts(rows); } } + static void _primeContactCache(Map contact) { + final id = contact['id']; + if (id is! int) return; + + final names = contact['names']; + if (names is List && names.isNotEmpty) { + final nameRaw = names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.firstWhere((n) => n is Map, orElse: () => null), + ); + if (nameRaw is Map) { + final firstName = (nameRaw['firstName'] as String?) ?? ''; + final lastName = nameRaw['lastName'] as String?; + final fullName = (lastName != null && lastName.isNotEmpty) + ? '$firstName $lastName' + : firstName; + if (fullName.isNotEmpty) ContactCache.put(id, fullName); + } + } + + final baseUrl = contact['baseUrl'] as String?; + if (baseUrl != null && baseUrl.isNotEmpty) { + ContactCache.putAvatar(id, baseUrl); + } + } + static Future> getContacts(int accountId) async { final rows = await AppDatabase.loadContacts(accountId); return rows.map(CachedContact.fromDbRow).toList(); } + /// Прогревает in-memory ContactCache из локальных контактов. + /// Нужно вызывать на cold start: иначе кэш пуст до следующего логина. + static Future primeCacheFromDb(int accountId) async { + final contacts = await getContacts(accountId); + for (final c in contacts) { + final fullName = (c.lastName != null && c.lastName!.isNotEmpty) + ? '${c.firstName} ${c.lastName}' + : c.firstName; + if (fullName.isNotEmpty) ContactCache.put(c.id, fullName); + if (c.baseUrl != null && c.baseUrl!.isNotEmpty) { + ContactCache.putAvatar(c.id, c.baseUrl); + } + } + } + static Map? _parseContact( Map contact, int accountId, diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 5637328..e8b3e27 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'dart:isolate'; import 'package:dart_lz4/dart_lz4.dart'; +import 'package:es_compression/zstd.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; /// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10 @@ -129,21 +130,7 @@ Future unpackPacket(Uint8List packet) async { if (payloadBytes.isNotEmpty) { if (compFlag != 0) { - try { - payloadBytes = lz4Decompress( - payloadBytes, - decompressedSize: _maxDecompressedSize, - ); - } catch (_) { - try { - payloadBytes = _lz4BlockDecompress( - payloadBytes, - _maxDecompressedSize, - ); - } catch (e) { - throw Exception("LZ4 decompression error: $e"); - } - } + payloadBytes = _decompressPayload(payloadBytes); } try { @@ -165,6 +152,44 @@ Future unpackPacket(Uint8List packet) async { }); } +/// Определяет формат сжатия по magic-number и распаковывает payload. +/// Сервер может присылать LZ4 block ИЛИ Zstandard в зависимости от ответа. +Uint8List _decompressPayload(Uint8List src) { + // Zstandard: magic 28 B5 2F FD (little-endian) + if (src.length >= 4 && + src[0] == 0x28 && + src[1] == 0xB5 && + src[2] == 0x2F && + src[3] == 0xFD) { + try { + final out = zstd.decode(src); + return out is Uint8List ? out : Uint8List.fromList(out); + } catch (e) { + throw Exception('Zstd decompression error: $e'); + } + } + + // LZ4 frame: magic 04 22 4D 18 + if (src.length >= 4 && + src[0] == 0x04 && + src[1] == 0x22 && + src[2] == 0x4D && + src[3] == 0x18) { + try { + return lz4Decompress(src, decompressedSize: _maxDecompressedSize); + } catch (e) { + throw Exception('LZ4 frame decompression error: $e'); + } + } + + // По умолчанию — LZ4 block (без magic) + try { + return _lz4BlockDecompress(src, _maxDecompressedSize); + } catch (e) { + throw Exception('LZ4 block decompression error: $e'); + } +} + /// LZ4 block декомпрессия (без frame-заголовка). /// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает. Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index a14476e..e0a45ee 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -4,15 +4,16 @@ import '../protocol/packet.dart'; import '../utils/logger.dart'; /// Буфер входящих данных. -/// Копит сырые байты из сокета, собирает из них целые пакеты. +/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов. class PacketReceiver { Uint8List _buffer = Uint8List(0); static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта - /// Добавляет байты в буфер, возвращает поток собранных пакетов. - /// Неполные данные остаются в буфере до следующего вызова. - Stream feed(Uint8List data) async* { + /// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы. + /// Полностью синхронный — нарезка не блокируется на распаковке, поэтому + /// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`. + List feed(Uint8List data) { final newBuffer = Uint8List(_buffer.length + data.length); newBuffer.setAll(0, _buffer); newBuffer.setAll(_buffer.length, data); @@ -23,9 +24,10 @@ class PacketReceiver { 'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс', ); reset(); - return; + return const []; } + final packets = []; while (_buffer.length >= headerSize) { final bd = ByteData.view( _buffer.buffer, @@ -38,15 +40,10 @@ class PacketReceiver { if (_buffer.length < totalLength) break; - final packetBytes = Uint8List.sublistView(_buffer, 0, totalLength); + packets.add(Uint8List.sublistView(_buffer, 0, totalLength)); _buffer = _buffer.sublist(totalLength); - - try { - yield await unpackPacket(packetBytes); - } catch (e) { - logger.e('PacketReceiver: ошибка распаковки: $e'); - } } + return packets; } void reset() { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 1217554..db9b7cf 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1270,15 +1270,17 @@ class MessageBubble extends StatelessWidget { } Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) { - final preview = (sticker as dynamic).previewData as String? ?? ''; + final url = sticker.baseUrl ?? ''; + final preview = sticker.previewData ?? ''; + final imageUrl = url.isNotEmpty ? url : preview; return ClipRRect( borderRadius: BorderRadius.circular(photoBorderRadius), child: Stack( children: [ - if (preview.isNotEmpty) + if (imageUrl.isNotEmpty) Image.network( - preview, + imageUrl, width: 150, height: 150, fit: BoxFit.contain, diff --git a/lib/main.dart b/lib/main.dart index 5c62627..1654efd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:komet/l10n/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; import 'backend/modules/account.dart'; +import 'backend/modules/contacts.dart'; import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; @@ -36,6 +37,10 @@ Future _loadInitialLocale() async { void main() async { WidgetsFlutterBinding.ensureInitialized(); await AppDatabase.init(); + final activeAccountId = await TokenStorage.getActiveAccountId(); + if (activeAccountId != null) { + await ContactsModule.primeCacheFromDb(activeAccountId); + } await api.connect(); final initialLocale = await _loadInitialLocale(); diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 825e348..4d72a09 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -304,9 +304,9 @@ class StickerAttachment extends MessageAttachment { return StickerAttachment( previewData: previewStr, - baseUrl: map['baseUrl'] as String?, - stickerId: map['stickerId'] as String?, - stickerPackId: map['stickerPackId'] as String?, + baseUrl: (map['url'] ?? map['baseUrl'])?.toString(), + stickerId: map['stickerId']?.toString(), + stickerPackId: (map['stickerPackId'] ?? map['setId'])?.toString(), width: map['width'] as int?, height: map['height'] as int?, ); diff --git a/pubspec.lock b/pubspec.lock index 1f7d572..e834ec0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -105,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" + es_compression: + dependency: "direct main" + description: + name: es_compression + sha256: c1ff7af54802631cf5c3942cb67bb99daadcc087f573ca99a9de91002d1a7ece + url: "https://pub.dev" + source: hosted + version: "2.0.15" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f681c96..017572f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 dart_lz4: ^1.0.0 + es_compression: ^2.0.15 msgpack_dart: ^1.0.1 logger: ^2.6.2 device_info_plus: 12.3.0 From 358b82a12c02f7c4fbffe77f0f72b4429960c44f Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 17:53:27 +0700 Subject: [PATCH 092/323] =?UTF-8?q?=D1=87=D0=B5=D1=82=20=D0=BF=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B8=D0=BB=20+=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=BA=D0=BE=D0=BD=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 80 ++++ lib/frontend/screens/chats/chat_screen.dart | 61 ++- lib/frontend/widgets/attachment_panel.dart | 390 ++++++++++++++++++++ lib/frontend/widgets/message_bubble.dart | 4 +- pubspec.lock | 28 +- pubspec.yaml | 3 + 6 files changed, 549 insertions(+), 17 deletions(-) create mode 100644 lib/frontend/widgets/attachment_panel.dart diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 150e5ca..51af04e 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -51,6 +51,45 @@ class TranscriptionCache { static bool has(String messageId) => _cache.containsKey(messageId); } +class FileHistoryEntry { + final int fileId; + final String? url; + final String? token; + final DateTime sentAt; + + FileHistoryEntry({ + required this.fileId, + this.url, + this.token, + required this.sentAt, + }); +} + +class FileHistoryCache { + static final List _history = []; + + static List get history => List.unmodifiable(_history); + + static void add(FileHistoryEntry entry) { + _history.insert(0, entry); + if (_history.length > 50) _history.removeLast(); + } + + static bool get isEmpty => _history.isEmpty; +} + +class FileUploadInfo { + final String url; + final int fileId; + final String token; + + FileUploadInfo({ + required this.url, + required this.fileId, + required this.token, + }); +} + class CachedMessage { final String id; final int accountId; @@ -304,6 +343,47 @@ class MessagesModule { return TranscriptionResult(status: transcriptionStatus); } + Future requestUploadUrl({int count = 1}) async { + final payload = {'count': count}; + final response = await _api.sendRequest(Opcode.fileUpload, payload); + if (!response.isOk) return null; + + final data = response.payload; + if (data is! Map) return null; + + final infoList = data['info'] as List?; + if (infoList == null || infoList.isEmpty) return null; + + final info = infoList.first; + if (info is! Map) return null; + + return FileUploadInfo( + url: info['url'] as String? ?? '', + fileId: info['fileId'] as int? ?? 0, + token: info['token'] as String? ?? '', + ); + } + + Future sendFileMessage( + int chatId, + int fileId, { + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + {'_type': 'FILE', 'fileId': fileId} + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + return response.isOk; + } + Future downloadPhoto(String baseUrl, String photoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 13d8b3d..9d50c57 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -11,6 +11,7 @@ import '../../../core/storage/app_database.dart'; import '../../../models/attachment.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../widgets/message_bubble.dart'; +import '../../widgets/attachment_panel.dart'; class ChatScreen extends StatefulWidget { final int chatId; @@ -37,6 +38,7 @@ class _ChatScreenState extends State bool _hasText = false; bool _isLoading = true; bool _isSending = false; + bool _showAttachmentPanel = false; late AnimationController _shimmerController; List _messages = []; int _myId = 0; @@ -336,14 +338,28 @@ class _ChatScreenState extends State ], ), )), - body: Column( + body: Stack( children: [ - Expanded( - child: _isLoading && _messages.isEmpty - ? _buildShimmerLoading() - : _buildMessagesList(), + Column( + children: [ + Expanded( + child: _isLoading && _messages.isEmpty + ? _buildShimmerLoading() + : _buildMessagesList(), + ), + _buildInputArea(context), + ], ), - _buildInputArea(context), + if (_showAttachmentPanel) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AttachmentPanel( + chatId: widget.chatId, + onClose: () => setState(() => _showAttachmentPanel = false), + ), + ), ], ), ); @@ -582,13 +598,32 @@ class _ChatScreenState extends State opacity: _hasText ? 0 : 1, child: _hasText ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.only(left: 12), - child: Icon( - Symbols.attachment, - color: mutedIcon, - size: 24, - weight: 400, + : GestureDetector( + onTap: _showAttachmentPanel ? null : () => setState(() => _showAttachmentPanel = true), + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Stack( + alignment: Alignment.center, + children: [ + if (_showAttachmentPanel) + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + Icon( + Symbols.attachment, + color: _showAttachmentPanel + ? cs.onSurfaceVariant.withValues(alpha: 0.3) + : mutedIcon, + size: 24, + weight: 400, + ), + ], + ), ), ), ), diff --git a/lib/frontend/widgets/attachment_panel.dart b/lib/frontend/widgets/attachment_panel.dart new file mode 100644 index 0000000..d749691 --- /dev/null +++ b/lib/frontend/widgets/attachment_panel.dart @@ -0,0 +1,390 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:komet/backend/modules/messages.dart' show FileHistoryCache, FileHistoryEntry; +import 'package:komet/core/config/proxy_config.dart'; +import 'package:komet/core/protocol/opcode_map.dart'; +import 'package:komet/core/protocol/packet.dart'; +import 'package:komet/core/transport/proxy_connector.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/main.dart' show api, messagesModule; +import 'package:material_symbols_icons/symbols.dart'; + +class AttachmentPanel extends StatefulWidget { + final int chatId; + final VoidCallback onClose; + + const AttachmentPanel({ + super.key, + required this.chatId, + required this.onClose, + }); + + @override + State createState() => _AttachmentPanelState(); +} + +class _AttachmentPanelState extends State { + final TextEditingController _fileIdController = TextEditingController(); + bool _isUploading = false; + + Future _pickAndUploadFile() async { + final result = await FilePicker.platform.pickFiles(); + if (result == null || result.files.isEmpty) return; + final file = result.files.first; + if (file.path == null) return; + + setState(() => _isUploading = true); + + try { + final uploadInfo = await messagesModule.requestUploadUrl(); + if (uploadInfo == null) { + if (mounted) showCustomNotification(context, 'Не удалось получить ссылку'); + return; + } + + final completer = Completer(); + void Function(Packet)? handler; + handler = (Packet packet) { + final payload = packet.payload; + if (payload is Map && payload['fileId'] == uploadInfo.fileId) { + api.unregisterPushHandler(Opcode.notifAttach); + completer.complete(); + } + }; + api.registerPushHandler(Opcode.notifAttach, (Packet p) => handler!(p)); + + await api.sendRequest(Opcode.msgTyping, { + 'chatId': widget.chatId, + 'type': 'FILE', + }); + + final uri = Uri.parse(uploadInfo.url); + final fileBytes = await File(file.path!).readAsBytes(); + final proxySettings = await ProxyConfig.load(); + + int statusCode; + if (proxySettings.isEnabled) { + final connector = ProxyConnector(proxySettings); + final proxySocket = await connector.connect(uri.host, uri.port); + final socket = uri.scheme == 'https' + ? await RawSecureSocket.secure( + proxySocket, + host: uri.host, + onBadCertificate: (_) => true, + ) + : proxySocket; + statusCode = await _rawPut(socket, uri, fileBytes); + } else { + final socket = await RawSocket.connect(uri.host, uri.port); + final secureSocket = uri.scheme == 'https' + ? await RawSecureSocket.secure( + socket, + host: uri.host, + onBadCertificate: (_) => true, + ) + : socket; + statusCode = await _rawPut(secureSocket, uri, fileBytes); + } + + if (statusCode == 200 || statusCode == 204) { + await completer.future.timeout( + const Duration(seconds: 30), + onTimeout: () { + api.unregisterPushHandler(Opcode.notifAttach); + throw TimeoutException('Тайм-аут подтверждения загрузки'); + }, + ); + + final sent = await messagesModule.sendFileMessage(widget.chatId, uploadInfo.fileId); + if (sent) { + FileHistoryCache.add(FileHistoryEntry( + fileId: uploadInfo.fileId, + url: uploadInfo.url, + token: uploadInfo.token, + sentAt: DateTime.now(), + )); + if (mounted) { + showCustomNotification(context, 'Файл отправлен'); + widget.onClose(); + } + } else { + if (mounted) showCustomNotification(context, 'Ошибка отправки сообщения'); + } + } else { + api.unregisterPushHandler(Opcode.notifAttach); + if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode'); + } + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _isUploading = false); + } + } + + Future _rawPut(RawSocket socket, Uri uri, List body) async { + final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; + final host = uri.host; + + // Try HttpClient-based approach first, fall back to raw socket + final httpClient = HttpClient(); + httpClient.badCertificateCallback = (cert, host, port) => true; + + try { + final request = await httpClient.putUrl(uri); + request.headers.contentType = ContentType('application', 'octet-stream'); + request.add(body is Uint8List ? body : Uint8List.fromList(body)); + final response = await request.close().timeout(const Duration(minutes: 5)); + final statusCode = response.statusCode; + await response.drain(); + debugPrint('HTTP Response via HttpClient: $statusCode'); + socket.close(); + return statusCode; + } catch (e) { + debugPrint('HttpClient failed, falling back to raw socket: $e'); + // Fallback to raw HTTP + final rawRequest = StringBuffer() + ..write('PUT $path HTTP/1.1\r\n') + ..write('Host: $host\r\n') + ..write('Content-Type: application/octet-stream\r\n') + ..write('Content-Length: ${body.length}\r\n') + ..write('Connection: close\r\n') + ..write('\r\n'); + + final requestBytes = utf8.encode(rawRequest.toString()); + final allBytes = [...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))]; + socket.write(Uint8List.fromList(allBytes)); + + final responseBytes = []; + final completer = Completer(); + Timer? timer; + + socket.listen((event) { + if (event == RawSocketEvent.read) { + final data = socket.read(); + if (data != null) responseBytes.addAll(data); + } else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) { + timer?.cancel(); + if (responseBytes.isEmpty) { + completer.completeError(const SocketException('Пустой ответ сервера')); + return; + } + final headerEnd = _findHeaderEnd(responseBytes); + if (headerEnd == -1) { + completer.completeError(const SocketException('Не удалось прочитать заголовок ответа')); + return; + } + final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true); + final statusLine = headerStr.split('\r\n').first; + debugPrint('HTTP Response (raw): $statusLine'); + final parts = statusLine.split(' '); + completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0); + } + }, onError: (e) { + timer?.cancel(); + completer.completeError(e); + }); + + timer = Timer(const Duration(minutes: 5), () { + socket.close(); + completer.completeError(TimeoutException('Тайм-аут загрузки')); + }); + + return completer.future; + } + } + + int _findHeaderEnd(List bytes) { + for (var i = 0; i < bytes.length - 3; i++) { + if (bytes[i] == 0x0D && bytes[i + 1] == 0x0A && + bytes[i + 2] == 0x0D && bytes[i + 3] == 0x0A) { + return i + 4; + } + } + return -1; + } + + Future _uploadByFileId() async { + final fileIdStr = _fileIdController.text.trim(); + if (fileIdStr.isEmpty) return; + final fileId = int.tryParse(fileIdStr); + if (fileId == null) { + if (mounted) showCustomNotification(context, 'Неверный fileId'); + return; + } + setState(() => _isUploading = true); + try { + final sent = await messagesModule.sendFileMessage(widget.chatId, fileId); + if (sent) { + if (mounted) { + showCustomNotification(context, 'Файл отправлен'); + widget.onClose(); + } + } else { + if (mounted) showCustomNotification(context, 'Ошибка отправки'); + } + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _isUploading = false); + } + } + + @override + void dispose() { + _fileIdController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GestureDetector( + onVerticalDragEnd: (details) { + if (details.velocity.pixelsPerSecond.dy > 300) widget.onClose(); + }, + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), + ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 8), + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row(children: [ + Expanded(child: _buildButton( + label: 'Выбрать из файла', + icon: Symbols.folder_open, + filled: true, + onTap: _isUploading ? null : _pickAndUploadFile, + cs: cs, + )), + const SizedBox(width: 8), + Expanded(child: _buildButton( + label: 'Отправить по id', + icon: null, + filled: false, + onTap: _isUploading ? null : _uploadByFileId, + cs: cs, + )), + ]), + ), + if (_isUploading) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: LinearProgressIndicator(), + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _fileIdController, + style: TextStyle(color: cs.onSurface, fontSize: 14), + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'fileId...', + hintStyle: TextStyle(color: cs.onSurfaceVariant), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + const Divider(height: 16), + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text('История', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500)), + ), + ), + if (FileHistoryCache.isEmpty) + Padding( + padding: const EdgeInsets.all(24), + child: Text('история пуста...', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)), + ) + else + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: FileHistoryCache.history.length, + itemBuilder: (ctx, idx) { + final e = FileHistoryCache.history[idx]; + return Container( + width: 72, + margin: const EdgeInsets.only(right: 8, bottom: 8), + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), + ), + child: Center(child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.description, color: cs.onSurfaceVariant, size: 28), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text('${e.fileId}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center), + ), + ], + )), + ); + }, + ), + ), + const SizedBox(height: 8), + ]), + ), + ); + } + + Widget _buildButton({ + required String label, + required IconData? icon, + required bool filled, + required VoidCallback? onTap, + required ColorScheme cs, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: filled ? cs.primaryContainer : cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: filled ? null : Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: filled ? cs.onPrimaryContainer : cs.onSurface), + const SizedBox(width: 6), + ], + Text(label, style: TextStyle( + color: filled ? cs.onPrimaryContainer : cs.onSurface, + fontWeight: FontWeight.w500, + fontSize: 13, + )), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 69ec7f2..949ae9f 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -168,9 +168,9 @@ class MessageBubble extends StatelessWidget { ); case BubbleShape.groupedMiddle: return BorderRadius.only( - topLeft: cornerTL, + topLeft: isMe ? cornerTL : smallRadius, topRight: smallRadius, - bottomLeft: cornerBL, + bottomLeft: isMe ? cornerBL : smallRadius, bottomRight: smallRadius, ); } diff --git a/pubspec.lock b/pubspec.lock index 1644efb..d510664 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -169,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" fixnum: dependency: transitive description: @@ -203,6 +219,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_secure_storage: dependency: "direct main" description: @@ -294,7 +318,7 @@ packages: source: hosted version: "1.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -302,7 +326,7 @@ packages: source: hosted version: "1.6.0" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" diff --git a/pubspec.yaml b/pubspec.yaml index 2e32aa1..cf9fd81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,9 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 flutter_secure_storage: ^10.0.0 + http: ^1.4.0 + http_parser: ^4.1.0 + file_picker: ^8.0.0 sqflite: ^2.4.2 sqflite_common_ffi: ^2.4.0+2 path: ^1.9.1 From fe512fc96d9a28782f606124ce771bcadafac160 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 18:10:05 +0700 Subject: [PATCH 093/323] =?UTF-8?q?=D0=B4=D0=B0=20=D1=85=D1=83=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=BE=D0=BD=D0=BE=20=D0=BD=D0=B5=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/attachment_panel.dart | 116 +++++++++------------ 1 file changed, 51 insertions(+), 65 deletions(-) diff --git a/lib/frontend/widgets/attachment_panel.dart b/lib/frontend/widgets/attachment_panel.dart index d749691..f32a964 100644 --- a/lib/frontend/widgets/attachment_panel.dart +++ b/lib/frontend/widgets/attachment_panel.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:convert'; +import 'dart:convert' show utf8; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -77,7 +77,7 @@ class _AttachmentPanelState extends State { onBadCertificate: (_) => true, ) : proxySocket; - statusCode = await _rawPut(socket, uri, fileBytes); + statusCode = await _rawPost(socket, uri, fileBytes, file.name); } else { final socket = await RawSocket.connect(uri.host, uri.port); final secureSocket = uri.scheme == 'https' @@ -87,10 +87,10 @@ class _AttachmentPanelState extends State { onBadCertificate: (_) => true, ) : socket; - statusCode = await _rawPut(secureSocket, uri, fileBytes); + statusCode = await _rawPost(secureSocket, uri, fileBytes, file.name); } - if (statusCode == 200 || statusCode == 204) { + if (statusCode == 200) { await completer.future.timeout( const Duration(seconds: 30), onTimeout: () { @@ -125,76 +125,62 @@ class _AttachmentPanelState extends State { } } - Future _rawPut(RawSocket socket, Uri uri, List body) async { + Future _rawPost(RawSocket socket, Uri uri, List body, String filename) async { final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; final host = uri.host; + final total = body.length; - // Try HttpClient-based approach first, fall back to raw socket - final httpClient = HttpClient(); - httpClient.badCertificateCallback = (cert, host, port) => true; + final request = StringBuffer() + ..write('POST $path HTTP/1.1\r\n') + ..write('Host: $host\r\n') + ..write('Content-Type: application/x-binary; charset=x-user-defined\r\n') + ..write('Content-Disposition: attachment; filename=$filename\r\n') + ..write('Connection: keep-alive\r\n') + ..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n') + ..write('Content-Range: bytes 0-${total - 1}/$total\r\n') + ..write('Content-Length: $total\r\n') + ..write('\r\n'); - try { - final request = await httpClient.putUrl(uri); - request.headers.contentType = ContentType('application', 'octet-stream'); - request.add(body is Uint8List ? body : Uint8List.fromList(body)); - final response = await request.close().timeout(const Duration(minutes: 5)); - final statusCode = response.statusCode; - await response.drain(); - debugPrint('HTTP Response via HttpClient: $statusCode'); - socket.close(); - return statusCode; - } catch (e) { - debugPrint('HttpClient failed, falling back to raw socket: $e'); - // Fallback to raw HTTP - final rawRequest = StringBuffer() - ..write('PUT $path HTTP/1.1\r\n') - ..write('Host: $host\r\n') - ..write('Content-Type: application/octet-stream\r\n') - ..write('Content-Length: ${body.length}\r\n') - ..write('Connection: close\r\n') - ..write('\r\n'); + final requestBytes = utf8.encode(request.toString()); + final allBytes = [...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))]; + socket.write(Uint8List.fromList(allBytes)); - final requestBytes = utf8.encode(rawRequest.toString()); - final allBytes = [...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))]; - socket.write(Uint8List.fromList(allBytes)); + final responseBytes = []; + final completer = Completer(); + Timer? timer; - final responseBytes = []; - final completer = Completer(); - Timer? timer; - - socket.listen((event) { - if (event == RawSocketEvent.read) { - final data = socket.read(); - if (data != null) responseBytes.addAll(data); - } else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) { - timer?.cancel(); - if (responseBytes.isEmpty) { - completer.completeError(const SocketException('Пустой ответ сервера')); - return; - } - final headerEnd = _findHeaderEnd(responseBytes); - if (headerEnd == -1) { - completer.completeError(const SocketException('Не удалось прочитать заголовок ответа')); - return; - } - final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true); - final statusLine = headerStr.split('\r\n').first; - debugPrint('HTTP Response (raw): $statusLine'); - final parts = statusLine.split(' '); - completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0); - } - }, onError: (e) { + socket.listen((event) { + if (event == RawSocketEvent.read) { + final data = socket.read(); + if (data != null) responseBytes.addAll(data); + } else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) { timer?.cancel(); - completer.completeError(e); - }); + if (responseBytes.isEmpty) { + completer.completeError(const SocketException('Пустой ответ сервера')); + return; + } + final headerEnd = _findHeaderEnd(responseBytes); + if (headerEnd == -1) { + completer.completeError(const SocketException('Не удалось прочитать заголовок ответа')); + return; + } + final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true); + final statusLine = headerStr.split('\r\n').first; + debugPrint('HTTP Response: $statusLine'); + final parts = statusLine.split(' '); + completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0); + } + }, onError: (e) { + timer?.cancel(); + completer.completeError(e); + }); - timer = Timer(const Duration(minutes: 5), () { - socket.close(); - completer.completeError(TimeoutException('Тайм-аут загрузки')); - }); + timer = Timer(const Duration(minutes: 5), () { + socket.close(); + completer.completeError(TimeoutException('Тайм-аут загрузки')); + }); - return completer.future; - } + return completer.future; } int _findHeaderEnd(List bytes) { From 4297ebc3cf071a48cce163d70e02edc19276b5c5 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 14 May 2026 14:41:52 +0300 Subject: [PATCH 094/323] =?UTF-8?q?perf:=20=D0=BA=D1=8D=D1=88=20=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B2=D0=B0=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=B6=D0=B4=D1=83=20=D1=80=D0=B5=D0=B1=D0=B8=D0=BB=D0=B4=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index a2ffb88..bb9d2fa 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -107,6 +107,37 @@ class _ChatListScreenState extends State StreamSubscription? _stateSub; StreamSubscription? _loginSub; + Widget? _cachedChatsBody; + Object? _chatsBodyCacheKey; + + /// Возвращает дерево вкладки «Чаты», кэшируя его между ребилдами + /// родителя. Тап/драг навбара и FAB не трогают эти state-vars, + /// поэтому ключ остаётся прежним и subtree не пересобирается. + Widget _getChatsBody() { + final key = Object.hashAll([ + identityHashCode(_chats), + identityHashCode(_folders), + _selectedFolderId, + _isInitialLoading, + _foldersListKnown, + _showCacheWarning, + _isSelectionMode, + _shouldCollapseSearch, + _selectedChats.length, + _pullRatio, + _storiesDockedOpen, + _storiesAnimClosing, + _storiesOverscrollRevealArmed, + _sessionState, + identityHashCode(_profile), + ]); + if (_cachedChatsBody == null || _chatsBodyCacheKey != key) { + _chatsBodyCacheKey = key; + _cachedChatsBody = _buildChatsTabBody(); + } + return _cachedChatsBody!; + } + void _toggleSelection(String chatId) { setState(() { if (_selectedChats.contains(chatId)) { @@ -1406,7 +1437,7 @@ class _ChatListScreenState extends State child: SizedBox( width: pageW, height: pageH, - child: _buildChatsTabBody(), + child: _getChatsBody(), ), ), RepaintBoundary( From ef8c9f6cabf3862c3edcb18f384086eca9816366 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 20:49:22 +0700 Subject: [PATCH 095/323] =?UTF-8?q?=D1=8D=D0=B2=D0=B5=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 6 +- lib/backend/modules/messages.dart | 21 +++- lib/core/storage/spoofing_service.dart | 2 +- lib/frontend/screens/chats/chat_screen.dart | 1 + lib/frontend/widgets/attachment_panel.dart | 109 ++++++++++++++++---- lib/frontend/widgets/message_bubble.dart | 90 +++++++++++++++- lib/models/attachment.dart | 11 +- 7 files changed, 205 insertions(+), 35 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 1fdd71f..d170153 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -122,11 +122,7 @@ class Api { Future sendHandshake() async { final deviceInfo = DeviceInfoPlugin(); - String deviceType = (Platform.isLinux || Platform.isWindows) - ? 'DESKTOP' - : (Platform.isAndroid) - ? 'ANDROID' - : 'IOS'; + String deviceType = 'ANDROID'; String osVersion = ''; String deviceName = 'Unknown'; String architecture = 'arm64'; diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 51af04e..06ac5c7 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -100,6 +100,7 @@ class CachedMessage { final String? status; final Map? payload; final List? attachments; + final bool isControl; const CachedMessage({ required this.id, @@ -111,6 +112,7 @@ class CachedMessage { this.status, this.payload, this.attachments, + this.isControl = false, }); factory CachedMessage.fromDbRow(Map row) { @@ -151,6 +153,7 @@ class CachedMessage { status: row['status']?.toString(), payload: payload, attachments: attachments, + isControl: attachments?.any((a) => a.type == AttachmentType.control) ?? false, ); } @@ -261,6 +264,7 @@ class MessagesModule { } List? attachments; + bool isControl = false; if (linkType == 'FORWARD') { final fwdMap = Map.from(m.cast()); attachments = [ForwardedMessageAttachment.fromMap(fwdMap)]; @@ -271,6 +275,11 @@ class MessagesModule { .whereType() .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); + // Detect CONTROL + if (attachments.any((a) => a.type == AttachmentType.control)) { + isControl = true; + debugPrint('CONTROL detected: ${attachments.where((a) => a.type == AttachmentType.control).first}'); + } } } @@ -284,6 +293,7 @@ class MessagesModule { status: m['status']?.toString(), payload: Map.from(m.cast()), attachments: attachments, + isControl: isControl, ); } @@ -367,14 +377,21 @@ class MessagesModule { Future sendFileMessage( int chatId, int fileId, { + String? token, bool notify = true, }) async { final payload = { 'chatId': chatId, 'message': { - 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'isLive': false, + 'detectShare': false, + 'elements': [], + 'cid': DateTime.now().millisecondsSinceEpoch, 'attaches': [ - {'_type': 'FILE', 'fileId': fileId} + if (token != null) + {'_type': 'FILE', 'token': token} + else + {'_type': 'FILE', 'fileId': fileId} ], }, 'notify': notify, diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index c27240b..548f5ad 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -1,7 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class SpoofingService { - static const String hardcodedAppVersion = '26.15.3'; + static const String hardcodedAppVersion = '26.14.1'; static const int hardcodedBuildNumber = 6606; static Future?> getSpoofedSessionData() async { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 9d50c57..73f1aed 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -384,6 +384,7 @@ class _ChatScreenState extends State itemCount: _messages.length, itemBuilder: (context, index) { final message = _messages[_messages.length - 1 - index]; + debugPrint('LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}'); final isMe = message.senderId == _myId; final prevMessage = index < _messages.length - 1 ? _messages[_messages.length - 2 - index] diff --git a/lib/frontend/widgets/attachment_panel.dart b/lib/frontend/widgets/attachment_panel.dart index f32a964..ffe3533 100644 --- a/lib/frontend/widgets/attachment_panel.dart +++ b/lib/frontend/widgets/attachment_panel.dart @@ -46,17 +46,6 @@ class _AttachmentPanelState extends State { return; } - final completer = Completer(); - void Function(Packet)? handler; - handler = (Packet packet) { - final payload = packet.payload; - if (payload is Map && payload['fileId'] == uploadInfo.fileId) { - api.unregisterPushHandler(Opcode.notifAttach); - completer.complete(); - } - }; - api.registerPushHandler(Opcode.notifAttach, (Packet p) => handler!(p)); - await api.sendRequest(Opcode.msgTyping, { 'chatId': widget.chatId, 'type': 'FILE', @@ -90,17 +79,61 @@ class _AttachmentPanelState extends State { statusCode = await _rawPost(secureSocket, uri, fileBytes, file.name); } - if (statusCode == 200) { - await completer.future.timeout( - const Duration(seconds: 30), + if (statusCode != 200) { + if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode'); + return; + } + + // Wait for notifAttach push + final pushCompleter = Completer(); + void Function(Packet)? pushHandler; + pushHandler = (Packet packet) { + final payload = packet.payload; + if (payload is Map && payload['fileId'] == uploadInfo.fileId) { + api.unregisterPushHandler(Opcode.notifAttach); + pushCompleter.complete(); + } + }; + api.registerPushHandler(Opcode.notifAttach, (Packet p) => pushHandler!(p)); + + await pushCompleter.future.timeout( + const Duration(seconds: 30), + onTimeout: () { + api.unregisterPushHandler(Opcode.notifAttach); + throw TimeoutException('Тайм-аут подтверждения загрузки'); + }, + ); + + // Retry loop: server may say "attachment in progress" (cmd=3) + for (var attempt = 0; attempt < 5; attempt++) { + final sent = await messagesModule.sendFileMessage( + widget.chatId, + uploadInfo.fileId, + token: uploadInfo.token, + ); + + // Listen for push again (another notifAttach may come) + final msgCompleter = Completer(); + void Function(Packet)? msgHandler; + msgHandler = (Packet packet) { + final payload = packet.payload; + if (payload is Map && payload['fileId'] == uploadInfo.fileId) { + api.unregisterPushHandler(Opcode.notifAttach); + msgCompleter.complete(true); + } + }; + api.registerPushHandler(Opcode.notifAttach, (Packet p) => msgHandler!(p)); + + final pushFuture = msgCompleter.future.timeout( + const Duration(seconds: 5), onTimeout: () { api.unregisterPushHandler(Opcode.notifAttach); - throw TimeoutException('Тайм-аут подтверждения загрузки'); + return false; }, ); - final sent = await messagesModule.sendFileMessage(widget.chatId, uploadInfo.fileId); - if (sent) { + final pushReceived = await pushFuture; + if (pushReceived && sent) { FileHistoryCache.add(FileHistoryEntry( fileId: uploadInfo.fileId, url: uploadInfo.url, @@ -111,13 +144,45 @@ class _AttachmentPanelState extends State { showCustomNotification(context, 'Файл отправлен'); widget.onClose(); } - } else { - if (mounted) showCustomNotification(context, 'Ошибка отправки сообщения'); + return; } - } else { - api.unregisterPushHandler(Opcode.notifAttach); - if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode'); + + // If push was received, check if message was sent + if (pushReceived) { + FileHistoryCache.add(FileHistoryEntry( + fileId: uploadInfo.fileId, + url: uploadInfo.url, + token: uploadInfo.token, + sentAt: DateTime.now(), + )); + if (mounted) { + showCustomNotification(context, 'Файл отправлен'); + widget.onClose(); + } + return; + } + + if (!sent) { + // msgSend failed, maybe server still processing — wait and retry + await Future.delayed(Duration(seconds: 1 + attempt)); + continue; + } + + // Sent ok, no push received (already processed earlier) + FileHistoryCache.add(FileHistoryEntry( + fileId: uploadInfo.fileId, + url: uploadInfo.url, + token: uploadInfo.token, + sentAt: DateTime.now(), + )); + if (mounted) { + showCustomNotification(context, 'Файл отправлен'); + widget.onClose(); + } + return; } + + if (mounted) showCustomNotification(context, 'Не удалось отправить сообщение'); } catch (e) { if (mounted) showCustomNotification(context, 'Ошибка: $e'); } finally { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 949ae9f..a945ccc 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -8,7 +8,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; import '../../models/attachment.dart'; -enum MessageType { text, attachment, voice } +enum MessageType { text, attachment, voice, control } enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } @@ -56,18 +56,23 @@ class MessageBubble extends StatelessWidget { bool get isGroupedWithNext { if (nextMessage == null) return false; + if (message.isControl) return false; if (nextMessage!.senderId != message.senderId) return false; final timeDiff = nextMessage!.time - message.time; return timeDiff < 300000; } BubbleShape get shape { - final hasPrevFromMe = prevMessage?.senderId == message.senderId; + if (message.isControl) { + return BubbleShape.singleMiddle; + } + + final hasPrevFromMe = prevMessage?.senderId == message.senderId && !prevMessage!.isControl; final prevTimeDiff = hasPrevFromMe ? message.time - prevMessage!.time : 999999999; - final hasNextFromMe = nextMessage?.senderId == message.senderId; + final hasNextFromMe = nextMessage?.senderId == message.senderId && !nextMessage!.isControl; final nextTimeDiff = hasNextFromMe ? nextMessage!.time - message.time : 999999999; @@ -83,6 +88,7 @@ class MessageBubble extends StatelessWidget { } MessageType get contentType { + if (message.isControl) return MessageType.control; if (message.attachments != null && message.attachments!.isNotEmpty) { final first = message.attachments!.first; if (first is ForwardedMessageAttachment) { @@ -214,6 +220,8 @@ class MessageBubble extends StatelessWidget { case BubbleShape.groupedMiddle: return 1; } + case MessageType.control: + return 4; } return 4; } @@ -242,6 +250,17 @@ class MessageBubble extends StatelessWidget { case BubbleShape.groupedMiddle: return 1; } + case MessageType.attachment: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } case MessageType.voice: switch (shape) { case BubbleShape.singleTop: @@ -253,6 +272,8 @@ class MessageBubble extends StatelessWidget { case BubbleShape.groupedMiddle: return 1; } + case MessageType.control: + return 4; } return 4; } @@ -284,12 +305,22 @@ class MessageBubble extends StatelessWidget { case BubbleShape.singleMiddle: return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); } + case MessageType.control: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 4); } return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); } @override Widget build(BuildContext context) { + if (message.isControl) { + debugPrint('BUILD CONTROL: ${message.id}'); + return Padding( + padding: EdgeInsets.only(top: topMargin, bottom: bottomMargin), + child: Center(child: _buildControlContent(context)), + ); + } + final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; @@ -362,16 +393,67 @@ class MessageBubble extends StatelessWidget { Widget _buildContent(BuildContext context) { switch (contentType) { + case MessageType.control: + return _buildControlContent(context); case MessageType.attachment: return _buildAttachmentContent(context); case MessageType.voice: return _buildVoiceContent(context); case MessageType.text: - default: return _buildTextContent(context); } } + Widget _buildControlContent(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final attachments = message.attachments; + if (attachments == null || attachments.isEmpty) return const SizedBox.shrink(); + + final control = attachments.first; + if (control is! ControlAttachment) return const SizedBox.shrink(); + + String? text; + switch (control.event) { + case 'system': + text = control.title; + break; + case 'new': + text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} создал(а) чат'; + break; + case 'add': + final names = (control.userIds ?? []).map((id) => ContactCache.get(id) ?? 'Пользователь').join(', '); + text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} добавил(а) $names'; + break; + case 'leave': + text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} покинул(а) чат'; + break; + case 'joinByLink': + text = '${ContactCache.get(message.senderId) ?? 'Пользователь'} присоединился(-ась) к чату'; + break; + default: + text = control.title; + } + + if (text == null || text.isEmpty) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + text, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ); + } + Widget _buildTextContent(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 795db87..dd76314 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -431,6 +431,7 @@ class ControlAttachment extends MessageAttachment { final String? event; final String? title; final List? userIds; + final int? userId; const ControlAttachment({ super.previewData, @@ -439,15 +440,22 @@ class ControlAttachment extends MessageAttachment { this.event, this.title, this.userIds, + this.userId, }) : super(type: AttachmentType.control); factory ControlAttachment.fromMap(Map map) { + String? title = map['title']?.toString(); + if ((title == null || title.isEmpty) && map['shortMessage'] != null) { + title = map['shortMessage'].toString(); + } + return ControlAttachment( previewData: map['previewData']?.toString(), baseUrl: map['baseUrl']?.toString(), event: map['event']?.toString(), - title: map['title']?.toString(), + title: title, userIds: (map['userIds'] as List?)?.map((e) => e is int ? e : int.tryParse(e?.toString() ?? '') ?? 0).toList(), + userId: map['userId'] is int ? map['userId'] as int : int.tryParse(map['userId']?.toString() ?? ''), ); } @@ -459,6 +467,7 @@ class ControlAttachment extends MessageAttachment { 'event': event, 'title': title, 'userIds': userIds, + 'userId': userId, }; } From 0f4ac31ae25a7a4e781376cdc036ac5e9e6342f5 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 14 May 2026 17:06:11 +0300 Subject: [PATCH 096/323] =?UTF-8?q?feat:=20=D0=B3=D0=B0=D0=BB=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=83=20=D0=BE=D1=84=D0=B8=D1=86=D0=B8=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9,=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 31 ++++++++++++-- lib/backend/modules/contacts.dart | 20 ++++++++++ lib/core/storage/app_database.dart | 14 ++++++- .../screens/chats/chat_list_screen.dart | 40 ++++++++++++++----- lib/frontend/screens/chats/chat_screen.dart | 35 ++++++++++++---- .../screens/contacts/contacts_tab.dart | 35 +++++++++++----- 6 files changed, 143 insertions(+), 32 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 2308163..8157055 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -38,6 +38,7 @@ class CachedChat { final bool isOnline; final int seenTime; final Map participants; + final Set options; const CachedChat({ required this.id, @@ -57,8 +58,11 @@ class CachedChat { required this.isOnline, required this.seenTime, required this.participants, + this.options = const {}, }); + bool get isOfficial => options.contains('OFFICIAL'); + factory CachedChat.fromDbRow(Map row) => CachedChat( id: row['id'] as int, accountId: row['account_id'] as int, @@ -76,9 +80,15 @@ class CachedChat { dontDisturbUntil: row['dont_disturb_until'] as int, isOnline: (row['is_online'] as int) == 1, seenTime: row['seen_time'] as int, - participants: _parseParticipants(row['participants']) + participants: _parseParticipants(row['participants']), + options: _decodeOptions(row['options']), ); + static Set _decodeOptions(dynamic raw) { + if (raw is! String || raw.isEmpty) return const {}; + return raw.split(',').where((s) => s.isNotEmpty).toSet(); + } + Map toDbRow() => { 'id': id, 'account_id': accountId, @@ -96,7 +106,8 @@ class CachedChat { 'dont_disturb_until': dontDisturbUntil, 'is_online': isOnline ? 1 : 0, 'seen_time': seenTime, - 'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))) + 'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))), + 'options': options.isEmpty ? null : options.join(','), }; } @@ -210,6 +221,7 @@ class ChatsModule { String? title; String? iconUrl; + Set options = const {}; if (type == 'DIALOG') { otherId = _otherParticipantId(chat['participants'], currentUserId); @@ -218,13 +230,25 @@ class ChatsModule { if (contact != null) { title = _nameFromContact(contact); iconUrl = contact['baseUrl'] as String?; + final contactOpts = contact['options']; + if (contactOpts is List) { + options = contactOpts.whereType().toSet(); + } } else { title = existing[id]?.title; iconUrl = existing[id]?.iconUrl; + options = existing[id]?.options ?? const {}; } } else { title = chat['title'] as String?; iconUrl = chat['baseIconUrl'] as String?; + final chatOpts = chat['options']; + if (chatOpts is Map) { + options = { + for (final entry in chatOpts.entries) + if (entry.value == true && entry.key is String) entry.key as String, + }; + } } final lastMsg = chat['lastMessage']; @@ -276,7 +300,8 @@ class ChatsModule { dontDisturbUntil: dontDisturbUntil, isOnline: isOnline, seenTime: seenTime, - participants: participants + participants: participants, + options: options, ); } catch (e) { logger.e("Ошибка при парсинге чата: $e"); diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index b4f103b..fda11db 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -11,6 +11,7 @@ class CachedContact { final String? baseUrl; final String? baseRawUrl; final int updateTime; + final Set options; const CachedContact({ required this.id, @@ -22,8 +23,14 @@ class CachedContact { this.baseUrl, this.baseRawUrl, required this.updateTime, + this.options = const {}, }); + bool get isOfficial => options.contains('OFFICIAL'); + bool get isBot => options.contains('BOT'); + bool get isServiceAccount => options.contains('SERVICE_ACCOUNT'); + bool get isVerified => isOfficial || isBot || isServiceAccount; + factory CachedContact.fromDbRow(Map row) => CachedContact( id: row['id'] as int, accountId: row['account_id'] as int, @@ -34,7 +41,13 @@ class CachedContact { baseUrl: row['base_url'] as String?, baseRawUrl: row['base_raw_url'] as String?, updateTime: row['update_time'] as int, + options: _decodeOptions(row['options']), ); + + static Set _decodeOptions(dynamic raw) { + if (raw is! String || raw.isEmpty) return const {}; + return raw.split(',').where((s) => s.isNotEmpty).toSet(); + } } class ContactsModule { @@ -126,6 +139,12 @@ class ContactsModule { lastName = name['lastName'] as String?; } + final optionsRaw = contact['options']; + String? optionsStr; + if (optionsRaw is List) { + optionsStr = optionsRaw.whereType().join(','); + } + return { 'id': id, 'account_id': accountId, @@ -136,6 +155,7 @@ class ContactsModule { 'base_url': contact['baseUrl'] as String?, 'base_raw_url': contact['baseRawUrl'] as String?, 'update_time': (contact['updateTime'] as int?) ?? 0, + 'options': optionsStr, }; } } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 24f02d9..7f7ff13 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -159,7 +159,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 8, + version: 9, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -193,6 +193,14 @@ class AppDatabase { 'ALTER TABLE chats_cache ADD COLUMN participants TEXT', ); } + if (oldVersion < 9) { + await db.execute( + 'ALTER TABLE contacts ADD COLUMN options TEXT', + ); + await db.execute( + 'ALTER TABLE chats_cache ADD COLUMN options TEXT', + ); + } }, ); } @@ -230,7 +238,8 @@ class AppDatabase { photo_id INTEGER, base_url TEXT, base_raw_url TEXT, - update_time INTEGER NOT NULL DEFAULT 0 + update_time INTEGER NOT NULL DEFAULT 0, + options TEXT ) '''; @@ -262,6 +271,7 @@ class AppDatabase { is_online INTEGER NOT NULL DEFAULT 0, seen_time INTEGER NOT NULL DEFAULT 0, participants TEXT NOT NULL DEFAULT "", + options TEXT, PRIMARY KEY (id, account_id) ) '''; diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index bb9d2fa..57eb64e 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1102,6 +1102,7 @@ class _ChatListScreenState extends State isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, + isVerified: chat.isOfficial, chatType: "DIALOG", ); } else { @@ -1134,6 +1135,7 @@ class _ChatListScreenState extends State isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, + isVerified: chat.isOfficial, chatType: chat.type, ); } @@ -1749,6 +1751,7 @@ class _ChatListScreenState extends State bool isRead = false, int unreadCount = 0, bool isMuted = false, + bool isVerified = false, String chatType = "CHAT", }) { final cs = Theme.of(context).colorScheme; @@ -1849,16 +1852,33 @@ Navigator.push( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1.1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isVerified) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], ), ), if (isMuted) ...[ diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 9d50c57..87a6bc8 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -304,14 +304,33 @@ class _ChatScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (chat?.isOfficial ?? false) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], ), Text( status ?? "", diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index a2d00d6..8acc382 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -100,15 +100,32 @@ class _ContactsTabState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - nameToDisplay, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + nameToDisplay, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (contact.isVerified) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], ), const SizedBox(height: 4), Text( From 9e293c12508bbe02e942c502beb3ee3264bf97d1 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 14 May 2026 18:01:36 +0300 Subject: [PATCH 097/323] =?UTF-8?q?fix:=20Zstd=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20pure-Dart=20libcompress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/protocol/packet.dart | 5 ++--- pubspec.lock | 24 ++++++++---------------- pubspec.yaml | 2 +- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index e8b3e27..d8527a1 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -1,7 +1,7 @@ import 'dart:typed_data'; import 'dart:isolate'; import 'package:dart_lz4/dart_lz4.dart'; -import 'package:es_compression/zstd.dart'; +import 'package:libcompress/libcompress.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; /// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10 @@ -162,8 +162,7 @@ Uint8List _decompressPayload(Uint8List src) { src[2] == 0x2F && src[3] == 0xFD) { try { - final out = zstd.decode(src); - return out is Uint8List ? out : Uint8List.fromList(out); + return ZstdCodec().decompress(src); } catch (e) { throw Exception('Zstd decompression error: $e'); } diff --git a/pubspec.lock b/pubspec.lock index d510664..00c6969 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" async: dependency: transitive description: @@ -145,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" - es_compression: - dependency: "direct main" - description: - name: es_compression - sha256: c1ff7af54802631cf5c3942cb67bb99daadcc087f573ca99a9de91002d1a7ece - url: "https://pub.dev" - source: hosted - version: "2.0.15" fake_async: dependency: transitive description: @@ -365,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + libcompress: + dependency: "direct main" + description: + name: libcompress + sha256: "1f55be8dc9e622efa1584ad899e05880d71469b7107f35be1858e91d0fadf1d4" + url: "https://pub.dev" + source: hosted + version: "1.0.0" lints: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cf9fd81..784ef10 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 dart_lz4: ^1.0.0 - es_compression: ^2.0.15 + libcompress: ^1.0.0 msgpack_dart: ^1.0.1 logger: ^2.6.2 device_info_plus: 12.3.0 From fd4f40a527da30e1e6257225ceb49248abace1d4 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 14 May 2026 22:58:40 +0700 Subject: [PATCH 098/323] =?UTF-8?q?=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B2=D1=85=D0=BE=D0=B4=20=D1=81=202fa=20=D0=B8=20?= =?UTF-8?q?=D1=8D=D0=B2=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B8=20=D1=82=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=B5=D1=89=D0=B5=20=D1=87=D0=B5=D1=82=D0=BE=20=D0=B1?= =?UTF-8?q?=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 56 +++++++++---------- .../screens/auth/password_2fa_screen.dart | 4 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 288a097..1a983be 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'package:flutter/material.dart' show Locale; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -803,15 +802,18 @@ class AccountModule { }) async { _ensureOnline(); - final resolvedAccountId = + int? resolvedAccountId = accountId ?? await TokenStorage.getActiveAccountId(); - if (resolvedAccountId == null) { - throw StateError('login: нет активного аккаунта'); - } - final authToken = token ?? await TokenStorage.readToken(resolvedAccountId); + String? authToken = token; if (authToken == null) { - throw StateError('login: нет токена для аккаунта $resolvedAccountId'); + if (resolvedAccountId == null) { + throw StateError('login: нет активного аккаунта'); + } + authToken = await TokenStorage.readToken(resolvedAccountId); + if (authToken == null) { + throw StateError('login: нет токена для аккаунта $resolvedAccountId'); + } } final requestPayload = _buildLoginPayload(authToken, syncParams); @@ -827,10 +829,24 @@ class AccountModule { throw Exception('login: неожиданный тип payload: ${data.runtimeType}'); } - final result = await _processLoginResponse( - data.cast(), - resolvedAccountId, - ); + final dataMap = data.cast(); + + if (resolvedAccountId == null) { + final profileMap = dataMap['profile']; + if (profileMap is Map) { + final contact = profileMap['contact']; + if (contact is Map) { + resolvedAccountId = contact['id'] as int?; + } + } + if (resolvedAccountId == null) { + throw Exception('login: не удалось определить accountId из ответа'); + } + await TokenStorage.saveToken(authToken, resolvedAccountId); + await TokenStorage.setActiveAccount(resolvedAccountId); + } + + final result = await _processLoginResponse(dataMap, resolvedAccountId); _loginStatusController.add(LoginStatus.success); return result; } catch (e) { @@ -938,23 +954,7 @@ class AccountModule { throw Exception('checkPassword: отсутствует токен в ответе'); } - final profileData = data['profile']; - int? accountId; - if (profileData is Map) { - final contact = profileData['contact']; - if (contact is Map) { - accountId = contact['id'] as int?; - } - } - - if (accountId != null) { - await TokenStorage.saveToken(loginToken, accountId); - await TokenStorage.setActiveAccount(accountId); - logger.i('2FA пройдена, токен аккаунта $accountId сохранён'); - } else { - logger.w('2FA пройдена, но accountId не получен из ответа'); - } - + logger.i('2FA пройдена, получен login-токен'); return TwoFactorResult(loginToken: loginToken); } diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index 9a99a42..b38171e 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -33,14 +33,14 @@ class _Password2FAScreenState extends State { }); try { - await accountModule.checkPassword( + final result = await accountModule.checkPassword( password: _passwordController.text, trackId: widget.trackId, ); if (!mounted) return; - await accountModule.login(); + await accountModule.login(token: result.loginToken); if (!mounted) return; From 5db3b2d13569a30fba4035d11d3cbbf19182d2eb Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 15 May 2026 07:26:27 +0300 Subject: [PATCH 099/323] =?UTF-8?q?feat:=20=D1=82=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=82=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B0=20+=20=D1=82=D1=83=D0=BC=D0=B1=D0=BB=D0=B5=D1=80=20?= =?UTF-8?q?=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/utils/haptics.dart | 83 +++++++++++++++++++ .../screens/chats/chat_list_screen.dart | 5 ++ lib/frontend/screens/chats/chat_screen.dart | 6 ++ .../screens/profile/settings_tab.dart | 52 ++++++++++-- lib/frontend/widgets/message_bubble.dart | 3 +- lib/main.dart | 3 + 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 lib/core/utils/haptics.dart diff --git a/lib/core/utils/haptics.dart b/lib/core/utils/haptics.dart new file mode 100644 index 0000000..b21328d --- /dev/null +++ b/lib/core/utils/haptics.dart @@ -0,0 +1,83 @@ +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Centralized tactile feedback for Komet. +/// +/// Wraps Flutter's [HapticFeedback] so the whole app speaks one tactile +/// "language": the same gesture always feels the same. Composite patterns +/// chain impacts with short delays to produce richer, more memorable +/// sensations than a single buzz. +/// +/// Every call is best-effort and silent on failure — a device without a +/// vibrator (or with system haptics disabled) must never crash the UI. +class Haptics { + Haptics._(); + + static const String _prefKey = 'haptics_enabled'; + + /// Master switch. Silences every haptic app-wide when `false`. + /// Controlled by the user via Settings; persisted across launches. + static bool enabled = true; + + /// Restores the saved preference. Call once during app startup, + /// before the first frame. Defaults to enabled when never set. + static Future load() async { + try { + final prefs = await SharedPreferences.getInstance(); + enabled = prefs.getBool(_prefKey) ?? true; + } catch (_) { + enabled = true; + } + } + + /// Updates the master switch and persists it. + static Future setEnabled(bool value) async { + enabled = value; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefKey, value); + } catch (_) { + // Persistence is best-effort; the in-memory switch still applies. + } + } + + static Future _fire(Future Function() effect) async { + if (!enabled) return; + try { + await effect(); + } catch (_) { + // Intentionally swallowed: haptics are a nicety, never a hard dependency. + } + } + + /// A crisp, light tick — taps, toggles, opening panels. + static Future tap() => _fire(HapticFeedback.lightImpact); + + /// A firmer press — confirmations, entering a mode. + static Future medium() => _fire(HapticFeedback.mediumImpact); + + /// A strong thud — destructive or weighty actions. + static Future heavy() => _fire(HapticFeedback.heavyImpact); + + /// The subtle detent of moving between discrete options — tabs, selection. + static Future selection() => _fire(HapticFeedback.selectionClick); + + /// Message sent: a quick, instant tick (the "whoosh"). + static Future send() => tap(); + + /// A two-beat rising pulse — success, completion, "it landed". + static Future success() async { + if (!enabled) return; + await _fire(HapticFeedback.lightImpact); + await Future.delayed(const Duration(milliseconds: 90)); + await _fire(HapticFeedback.mediumImpact); + } + + /// A double thud — errors, rejected or failed actions. + static Future error() async { + if (!enabled) return; + await _fire(HapticFeedback.heavyImpact); + await Future.delayed(const Duration(milliseconds: 120)); + await _fire(HapticFeedback.heavyImpact); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 57eb64e..2de522b 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -12,6 +12,7 @@ import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; import '../profile/settings_tab.dart'; import '../../../backend/api.dart'; +import '../../../core/utils/haptics.dart'; import '../../../backend/models/chat_folder.dart'; import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; @@ -139,6 +140,7 @@ class _ChatListScreenState extends State } void _toggleSelection(String chatId) { + Haptics.selection(); setState(() { if (_selectedChats.contains(chatId)) { _selectedChats.remove(chatId); @@ -729,6 +731,8 @@ class _ChatListScreenState extends State if (index == _currentNavIndex && !_navPageAnimController.isAnimating) { return; } + // Detent "click" when crossing into a different tab. + Haptics.selection(); double fromT; if (_navPageAnimController.isAnimating) { final t = Curves.easeOutCubic.transform(_navPageAnimController.value); @@ -743,6 +747,7 @@ class _ChatListScreenState extends State } void _toggleFab() { + Haptics.tap(); setState(() { _isFabOpen = !_isFabOpen; if (_isFabOpen) { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 655506a..068939c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -8,6 +8,7 @@ import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/haptics.dart'; import '../../../models/attachment.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../widgets/message_bubble.dart'; @@ -153,6 +154,10 @@ class _ChatScreenState extends State _hasText = false; }); + // Instant tactile "whoosh" the moment the message leaves the composer, + // not after the network round-trip — feedback must feel immediate. + Haptics.send(); + _scrollToBottom(); await messagesModule.sendMessage(_myId, widget.chatId, text); @@ -173,6 +178,7 @@ class _ChatScreenState extends State } } catch (e) { debugPrint('Error sending message: $e'); + Haptics.error(); } finally { setState(() { _isSending = false; diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index f38811b..313abef 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../auth/proxy_settings_sheet.dart'; @@ -30,6 +31,7 @@ class _SettingsTabState extends State { int _versionSecretTapCount = 0; Timer? _versionSecretTapResetTimer; StreamSubscription? _profileUpdateSub; + bool _hapticsEnabled = Haptics.enabled; @override void initState() { @@ -83,6 +85,13 @@ class _SettingsTabState extends State { }); } + Future _setHaptics(bool value) async { + await Haptics.setEnabled(value); + // Let the user *feel* the confirmation the instant they switch it on. + if (value) Haptics.success(); + if (mounted) setState(() => _hapticsEnabled = value); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -144,6 +153,12 @@ child: _buildSection( icon: Symbols.notifications_active, label: 'Уведомления и звук', ), + _SettingsItem( + icon: Symbols.vibration, + label: 'Тактильная отдача', + toggleValue: _hapticsEnabled, + onToggle: _setHaptics, + ), _SettingsItem( icon: Symbols.vpn_lock, label: 'Прокси', @@ -450,7 +465,9 @@ child: _buildSection( Material( color: Colors.transparent, child: InkWell( - onTap: item.onTap ?? () {}, + onTap: item.isToggle + ? () => item.onToggle!(!(item.toggleValue ?? false)) + : (item.onTap ?? () {}), borderRadius: isLast ? const BorderRadius.vertical(bottom: Radius.circular(20)) : null, @@ -475,12 +492,18 @@ child: _buildSection( ), ), ), - Icon( - Symbols.chevron_right, - color: cs.outline, - size: 20, - weight: 400, - ), + if (item.isToggle) + Switch.adaptive( + value: item.toggleValue ?? false, + onChanged: item.onToggle, + ) + else + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), ], ), ), @@ -505,7 +528,20 @@ class _SettingsItem { final String label; final VoidCallback? onTap; - const _SettingsItem({required this.icon, required this.label, this.onTap}); + /// When [onToggle] is set the row renders a trailing switch instead of a + /// chevron, and [toggleValue] reflects its current state. + final bool? toggleValue; + final ValueChanged? onToggle; + + const _SettingsItem({ + required this.icon, + required this.label, + this.onTap, + this.toggleValue, + this.onToggle, + }); + + bool get isToggle => onToggle != null; } class _PhoneSpoiler extends StatefulWidget { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index a945ccc..fdbe793 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -6,6 +6,7 @@ import 'package:komet/main.dart'; import 'package:flutter/foundation.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; +import '../../core/utils/haptics.dart'; import '../../models/attachment.dart'; enum MessageType { text, attachment, voice, control } @@ -329,7 +330,7 @@ class MessageBubble extends StatelessWidget { return GestureDetector( // TODO: действия с сообщением - onTap: () => print("test"), + onTap: () => Haptics.tap(), child: Padding( padding: EdgeInsets.only( left: isMe ? 12 : 12, diff --git a/lib/main.dart b/lib/main.dart index 37f1354..e78be9a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'backend/modules/contacts.dart'; import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; +import 'core/utils/haptics.dart'; import 'core/protocol/packet.dart'; import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; @@ -44,6 +45,8 @@ void main() async { await api.connect(); final initialLocale = await _loadInitialLocale(); + await Haptics.load(); + final prefs = await SharedPreferences.getInstance(); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; runApp( From 74917dd3ecd67b3e8552d9932b0ad8bd4297e685 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 15 May 2026 16:26:19 +0700 Subject: [PATCH 100/323] =?UTF-8?q?=D0=B4=D0=B0=D1=82=D1=8B,=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=87=D0=B8=D0=BD=D0=B8=D0=BB=20=D0=BA=D0=B0=D0=BA=D1=83?= =?UTF-8?q?=D1=8E=20=D1=82=D0=BE=20=D1=85=D1=83=D0=B9=D0=BD=D1=8E=20=D1=81?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 5 +- lib/frontend/screens/chats/chat_screen.dart | 259 ++++++++++++++++-- lib/main.dart | 8 +- 3 files changed, 242 insertions(+), 30 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 2de522b..13ebc9c 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1341,7 +1341,9 @@ class _ChatListScreenState extends State ), ), ), - Row( + SizedBox( + width: navInnerW, + child: Row( children: List.generate(4, (index) { IconData icon; String label; @@ -1389,6 +1391,7 @@ class _ChatListScreenState extends State ), ); }), + ), ), ], ), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 068939c..e37aca3 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -14,6 +14,18 @@ import '../../../backend/modules/messages.dart' show ContactCache; import '../../widgets/message_bubble.dart'; import '../../widgets/attachment_panel.dart'; +class _DateSeparatorItem { + final DateTime date; + final GlobalKey key; + _DateSeparatorItem(this.date, this.key); +} + +class _MessageItem { + final CachedMessage message; + final int index; + const _MessageItem(this.message, this.index); +} + class ChatScreen extends StatefulWidget { final int chatId; final String name; @@ -33,9 +45,10 @@ class ChatScreen extends StatefulWidget { } class _ChatScreenState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); + final GlobalKey _listKey = GlobalKey(); bool _hasText = false; bool _isLoading = true; bool _isSending = false; @@ -44,15 +57,28 @@ class _ChatScreenState extends State List _messages = []; int _myId = 0; CachedChat? chat; + + DateTime? _floatingDate; + DateTime? _lastFloatingDate; + Timer? _floatingDateTimer; + late final AnimationController _floatingDateAnimController; + final Map _separatorKeys = {}; + double _lastScrollOffset = 0; @override void initState() { super.initState(); _messageController.addListener(_onTextChanged); + _scrollController.addListener(_onScrollForDate); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(); + _floatingDateAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + reverseDuration: const Duration(milliseconds: 380), + ); _loadHistory(); } @@ -111,6 +137,9 @@ class _ChatScreenState extends State @override void dispose() { _messageController.removeListener(_onTextChanged); + _scrollController.removeListener(_onScrollForDate); + _floatingDateTimer?.cancel(); + _floatingDateAnimController.dispose(); _messageController.dispose(); _scrollController.dispose(); _shimmerController.dispose(); @@ -257,6 +286,143 @@ class _ChatScreenState extends State }); } + List _buildCombinedItems() { + final List items = []; + final Set usedDates = {}; + + for (int i = 0; i < _messages.length; i++) { + final msg = _messages[i]; + final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time); + final dayMillis = DateTime(msgDate.year, msgDate.month, msgDate.day) + .millisecondsSinceEpoch; + + bool needSeparator = i == 0; + if (!needSeparator) { + final prevDate = + DateTime.fromMillisecondsSinceEpoch(_messages[i - 1].time); + final prevDayMillis = + DateTime(prevDate.year, prevDate.month, prevDate.day) + .millisecondsSinceEpoch; + needSeparator = dayMillis != prevDayMillis; + } + + if (needSeparator) { + _separatorKeys.putIfAbsent(dayMillis, () => GlobalKey()); + usedDates.add(dayMillis); + items.add(_DateSeparatorItem( + DateTime.fromMillisecondsSinceEpoch(dayMillis), + _separatorKeys[dayMillis]!, + )); + } + + items.add(_MessageItem(msg, i)); + } + + _separatorKeys.removeWhere((k, _) => !usedDates.contains(k)); + return items; + } + + void _onScrollForDate() { + if (!_scrollController.hasClients) return; + final currentOffset = _scrollController.position.pixels; + final scrollingUp = currentOffset > _lastScrollOffset; + _lastScrollOffset = currentOffset; + + _floatingDateTimer?.cancel(); + + if (!scrollingUp) { + _floatingDateAnimController.reverse(); + return; + } + + _floatingDateTimer = Timer(const Duration(seconds: 2), () { + if (mounted) _floatingDateAnimController.reverse(); + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateFloatingDate()); + } + + void _updateFloatingDate() { + if (!mounted) return; + DateTime? result; + + final listRenderBox = _listKey.currentContext?.findRenderObject(); + if (listRenderBox is! RenderBox) return; + + _separatorKeys.forEach((dayMillis, gkey) { + final ctx = gkey.currentContext; + if (ctx == null) return; + final box = ctx.findRenderObject(); + if (box is! RenderBox) return; + final pos = box.localToGlobal(Offset.zero, ancestor: listRenderBox); + if (pos.dy + box.size.height < 4) { + final date = DateTime.fromMillisecondsSinceEpoch(dayMillis); + if (result == null || date.isAfter(result!)) { + result = date; + } + } + }); + + if (result == null) return; + + final bool dateChanged = result != _lastFloatingDate; + _lastFloatingDate = result; + + if (result != _floatingDate) { + setState(() => _floatingDate = result); + } + + if (dateChanged) { + _floatingDateAnimController.forward(from: 0); + } else { + _floatingDateAnimController.forward(); + } + } + + String _formatDateLabel(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final d = DateTime(date.year, date.month, date.day); + + if (d == today) return 'Сегодня'; + if (d == yesterday) return 'Вчера'; + + const months = [ + 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', + ]; + if (date.year == now.year) { + return '${date.day} ${months[date.month - 1]}'; + } + return '${date.day} ${months[date.month - 1]} ${date.year}'; + } + + Widget _buildDateSeparatorWidget(BuildContext context, DateTime date, + {Key? key}) { + final cs = Theme.of(context).colorScheme; + return Padding( + key: key, + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _formatDateLabel(date), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -402,31 +568,74 @@ class _ChatScreenState extends State ); } - return ListView.builder( - controller: _scrollController, - reverse: true, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _messages.length, - itemBuilder: (context, index) { - final message = _messages[_messages.length - 1 - index]; - debugPrint('LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}'); - final isMe = message.senderId == _myId; - final prevMessage = index < _messages.length - 1 - ? _messages[_messages.length - 2 - index] - : null; - final nextMessage = index > 0 - ? _messages[_messages.length - index] - : null; + final items = _buildCombinedItems(); - return MessageBubble( - message: message, - isMe: isMe, - myId: _myId, - prevMessage: prevMessage, - nextMessage: nextMessage, - chatType: chat?.type ?? 'CHAT', - ); - }, + return Stack( + key: _listKey, + children: [ + ListView.builder( + controller: _scrollController, + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 8), + cacheExtent: 9999, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[items.length - 1 - index]; + + if (item is _DateSeparatorItem) { + return _buildDateSeparatorWidget(context, item.date, + key: item.key); + } + + final msgItem = item as _MessageItem; + final message = msgItem.message; + final msgIndex = msgItem.index; + debugPrint( + 'LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}'); + final isMe = message.senderId == _myId; + final prevMessage = + msgIndex > 0 ? _messages[msgIndex - 1] : null; + final nextMessage = msgIndex < _messages.length - 1 + ? _messages[msgIndex + 1] + : null; + + return MessageBubble( + message: message, + isMe: isMe, + myId: _myId, + prevMessage: prevMessage, + nextMessage: nextMessage, + chatType: chat?.type ?? 'CHAT', + ); + }, + ), + if (_lastFloatingDate != null) + Positioned( + top: 8, + left: 0, + right: 0, + child: IgnorePointer( + child: AnimatedBuilder( + animation: _floatingDateAnimController, + builder: (context, child) { + final t = CurvedAnimation( + parent: _floatingDateAnimController, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ).value; + return Opacity( + opacity: t, + child: Transform.scale( + scale: 0.82 + 0.18 * t, + child: child, + ), + ); + }, + child: _buildDateSeparatorWidget(context, _lastFloatingDate!), + ), + ), + ), + ], ); } diff --git a/lib/main.dart b/lib/main.dart index e78be9a..c7623f4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -281,16 +281,16 @@ class _StartupScreenState extends State<_StartupScreen> { return; } + try { + await accountModule.login(accountId: accountId); + } catch (_) {} + if (mounted) { Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => const ChatListScreen()), ); } - - try { - await accountModule.login(accountId: accountId); - } catch (_) {} } void _goToLogin() { From f093e7c802417fe1aa6d1e1c95e090abb9c4169d Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 15 May 2026 17:02:22 +0700 Subject: [PATCH 101/323] =?UTF-8?q?=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B3=D0=B0=D0=BB=D0=BE=D1=87=D0=BA=D0=B8,=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=87=D0=B0=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 2 + lib/backend/modules/contacts.dart | 2 +- lib/backend/modules/messages.dart | 17 +++-- .../screens/chats/chat_list_screen.dart | 63 +++++++++++++++---- 4 files changed, 66 insertions(+), 18 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 8157055..e2251d2 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -132,6 +132,7 @@ class ChatsModule { final chatsConfig = configMap['chats'] is Map ? configMap['chats'] as Map : {}; + // Presence for online statuses final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; final cachedAt = DateTime.now().millisecondsSinceEpoch; @@ -272,6 +273,7 @@ class ChatsModule { dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; } + int seenTime = 0; bool isOnline = false; if (type == 'DIALOG' && otherId != null) { diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index fda11db..ade6ad3 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -29,7 +29,7 @@ class CachedContact { bool get isOfficial => options.contains('OFFICIAL'); bool get isBot => options.contains('BOT'); bool get isServiceAccount => options.contains('SERVICE_ACCOUNT'); - bool get isVerified => isOfficial || isBot || isServiceAccount; + bool get isVerified => isOfficial; factory CachedContact.fromDbRow(Map row) => CachedContact( id: row['id'] as int, diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 06ac5c7..b2b4b5c 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -8,19 +8,19 @@ import '../../models/attachment.dart'; class ContactCache { static final Map _nameCache = {}; static final Map _avatarCache = {}; + static final Map> _optionsCache = {}; - static void put(int id, String name) { - _nameCache[id] = name; - } + static void put(int id, String name) => _nameCache[id] = name; static void putAvatar(int id, String? baseUrl) { - if (baseUrl != null) { - _avatarCache[id] = baseUrl; - } + if (baseUrl != null) _avatarCache[id] = baseUrl; } + static void putOptions(int id, Set opts) => _optionsCache[id] = opts; + static String? get(int id) => _nameCache[id]; static String? getAvatar(int id) => _avatarCache[id]; + static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false; } class TranscriptionResult { @@ -545,6 +545,11 @@ class MessagesModule { final baseUrl = contact['baseUrl'] as String?; ContactCache.putAvatar(contactId, baseUrl); + final rawOpts = contact['options']; + if (rawOpts is List) { + ContactCache.putOptions(contactId, rawOpts.whereType().toSet()); + } + return fullName; } } diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 13ebc9c..906dadc 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -376,13 +376,21 @@ class _ChatListScreenState extends State } List _chatsForPageIndex(int pageIndex) { - if (_folders.isEmpty) return _chats; - if (pageIndex < 0 || pageIndex >= _folders.length) return _chats; - final folder = _folders[pageIndex]; - if (FoldersModule.isAllChatsFolder(folder)) return _chats; - return _chats - .where((c) => FoldersModule.chatMatchesFolder(c, folder)) - .toList(); + List base; + if (_folders.isEmpty) { + base = _chats; + } else if (pageIndex < 0 || pageIndex >= _folders.length) { + base = _chats; + } else { + final folder = _folders[pageIndex]; + base = FoldersModule.isAllChatsFolder(folder) + ? _chats + : _chats.where((c) => FoldersModule.chatMatchesFolder(c, folder)).toList(); + } + final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList() + ..sort((a, b) => a.favIndex!.compareTo(b.favIndex!)); + final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList(); + return [...pinned, ...regular]; } void _syncFolderChatScrollControllers() { @@ -1088,7 +1096,25 @@ class _ChatListScreenState extends State if (_isInitialLoading) { return _buildChatShimmer(); } - final chat = chats[index]; + + final pinnedCount = chats.where((c) => (c.favIndex ?? 0) > 0).length; + final hasSeparator = pinnedCount > 0 && pinnedCount < chats.length; + + // Insert separator row between pinned and regular sections + if (hasSeparator && index == pinnedCount) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Divider( + height: 1, + thickness: 0.5, + color: cs.outlineVariant.withValues(alpha: 0.5), + ), + ); + } + + final chatIndex = hasSeparator && index > pinnedCount ? index - 1 : index; + final chat = chats[chatIndex]; + final isPinned = (chat.favIndex ?? 0) > 0; if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { final secondId = chat.participants.entries @@ -1097,6 +1123,9 @@ class _ChatListScreenState extends State .key; final name = ContactCache.get(secondId); final avatar = ContactCache.getAvatar(secondId); + // ContactCache.isOfficial covers contacts loaded via opcode 32; + // chat.isOfficial covers contacts from the login payload. + final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial; return _buildChatItem( chat.id.toString(), @@ -1107,7 +1136,8 @@ class _ChatListScreenState extends State isOnline: chat.isOnline, unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, - isVerified: chat.isOfficial, + isVerified: isVerified, + isPinned: isPinned, chatType: "DIALOG", ); } else { @@ -1124,7 +1154,7 @@ class _ChatListScreenState extends State if (name?.isNotEmpty == true && chat.id != 0) { fullMsg += "$name: "; } - + if (chat.lastMsgText?.isNotEmpty == true) { fullMsg += chat.lastMsgText ?? ""; } @@ -1141,10 +1171,11 @@ class _ChatListScreenState extends State unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, isVerified: chat.isOfficial, + isPinned: isPinned, chatType: chat.type, ); } - }, childCount: _isInitialLoading ? 10 : chats.length), + }, childCount: _isInitialLoading ? 10 : chats.length + (chats.any((c) => (c.favIndex ?? 0) > 0) && chats.any((c) => (c.favIndex ?? 0) <= 0) ? 1 : 0)), ), SliverPadding( padding: EdgeInsets.only( @@ -1760,6 +1791,7 @@ class _ChatListScreenState extends State int unreadCount = 0, bool isMuted = false, bool isVerified = false, + bool isPinned = false, String chatType = "CHAT", }) { final cs = Theme.of(context).colorScheme; @@ -1898,6 +1930,15 @@ Navigator.push( weight: 400, ), ], + if (isPinned) ...[ + const SizedBox(width: 4), + Icon( + Symbols.keep, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + ], const SizedBox(width: 8), Text( time, From 0431db1c77f115dd6de989cbcd3e5e895719f3ad Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 15 May 2026 18:58:58 +0700 Subject: [PATCH 102/323] =?UTF-8?q?=D0=B8=D0=BD=D1=84=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 20 +- .../screens/chats/chat_info_screen.dart | 1220 +++++++++++++---- .../screens/chats/chat_list_screen.dart | 4 +- lib/frontend/screens/chats/chat_screen.dart | 178 ++- lib/frontend/widgets/message_bubble.dart | 72 +- 5 files changed, 1115 insertions(+), 379 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index b2b4b5c..6f172ae 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -278,7 +278,6 @@ class MessagesModule { // Detect CONTROL if (attachments.any((a) => a.type == AttachmentType.control)) { isControl = true; - debugPrint('CONTROL detected: ${attachments.where((a) => a.type == AttachmentType.control).first}'); } } } @@ -304,7 +303,7 @@ class MessagesModule { return int.tryParse(value.toString()) ?? 0; } - Future sendMessage( + Future sendMessage( int accountId, int chatId, String text, { @@ -321,7 +320,22 @@ class MessagesModule { 'notify': notify, }; - await _api.sendRequest(Opcode.msgSend, payload); + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) { + final msg = (response.payload is Map) + ? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка отправки') + : 'Ошибка отправки'; + throw Exception(msg.toString()); + } + final data = response.payload; + if (data is Map) { + final msgMap = data['message']; + if (msgMap is Map) { + final id = msgMap['id']; + if (id != null) return id.toString(); + } + } + return ''; } Future requestTranscription( diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 70a4ae7..6a1277a 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -1,9 +1,28 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/protocol/opcode_map.dart'; -import '../../../l10n/app_localizations.dart'; +import '../../../core/storage/app_database.dart'; import '../../../main.dart' as main; -import '../../widgets/custom_notification.dart'; + +class _MemberInfo { + final int id; + final bool isAdmin; + final bool isOwner; + final bool isMe; + final int? seenTime; + final bool isOnline; + + const _MemberInfo({ + required this.id, + required this.isAdmin, + required this.isOwner, + required this.isMe, + this.seenTime, + required this.isOnline, + }); +} class ChatInfoScreen extends StatefulWidget { final int chatId; @@ -23,362 +42,965 @@ class ChatInfoScreen extends StatefulWidget { State createState() => _ChatInfoScreenState(); } -class _ChatInfoScreenState extends State - with TickerProviderStateMixin { +class _ChatInfoScreenState extends State { + int _myId = 0; bool _isLoading = true; Map? _chatData; - late AnimationController _shimmerController; + + // DIALOG + int? _otherId; + Map? _contactData; + int? _seenTime; + bool _isOnline = false; + bool _isBot = false; + + bool _infoExpanded = false; + + // CHAT + List<_MemberInfo> _members = []; + int _onlineCount = 0; @override void initState() { super.initState(); - _shimmerController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - )..repeat(); - _loadChatData(); + _load(); } - @override - void dispose() { - _shimmerController.dispose(); - super.dispose(); - } - - Future _loadChatData() async { - try { - final packet = await main.api.sendRequest(Opcode.chatInfo, { - 'chatIds': [widget.chatId], - }); - - final payload = packet.payload as Map?; - if (payload == null) { - if (mounted) setState(() => _isLoading = false); - return; - } - - final errorField = payload['error']; - if (errorField != null) { - String errorMsg = 'Error'; - if (errorField is Map) { - errorMsg = errorField['localizedMessage'] ?? errorField['message'] ?? errorField.toString(); - } else if (errorField is String) { - errorMsg = errorField; - } - if (mounted) showCustomNotification(context, errorMsg); - setState(() => _isLoading = false); - return; - } - - final chats = payload['chats'] as List?; - if (chats != null && chats.isNotEmpty) { - _chatData = Map.from(chats.first as Map); - } else if (chats != null && chats.isEmpty) { - if (mounted) showCustomNotification(context, 'No data found'); - } + Future _load() async { + final profile = await AppDatabase.loadActiveProfile(); + _myId = profile?.id ?? 0; + final packet = await main.api.sendRequest( + Opcode.chatInfo, + {'chatIds': [widget.chatId]}, + ); + if (!packet.isOk || !mounted) { if (mounted) setState(() => _isLoading = false); - } catch (e) { - if (mounted) { - showCustomNotification(context, 'Error: $e'); - setState(() => _isLoading = false); - } + return; } + + final chats = (packet.payload as Map?)?['chats'] as List?; + if (chats == null || chats.isEmpty) { + if (mounted) setState(() => _isLoading = false); + return; + } + _chatData = Map.from(chats.first as Map); + + if (widget.chatType == 'DIALOG') { + final parts = _chatData!['participants'] as Map? ?? {}; + for (final key in parts.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null && id != _myId) { + _otherId = id; + break; + } + } + + if (_otherId != null) { + final cp = await main.api.sendRequest( + Opcode.contactInfo, + {'contactIds': [_otherId]}, + ); + if (cp.isOk) { + final contacts = (cp.payload as Map?)?['contacts'] as List?; + if (contacts != null && contacts.isNotEmpty) { + _contactData = Map.from(contacts.first as Map); + final opts = _contactData!['options']; + _isBot = (opts is List) && opts.contains('BOT'); + } + } + + final pp = await main.api.sendRequest( + Opcode.contactPresence, + {'contactIds': [_otherId]}, + ); + if (pp.isOk) { + final presence = (pp.payload as Map?)?['presence'] as Map?; + final p = presence?[_otherId.toString()] ?? presence?[_otherId]; + if (p is Map) { + _seenTime = p['seen'] as int?; + _isOnline = ((p['status'] as int?) ?? 0) > 0; + } + } + } + } else if (widget.chatType == 'CHAT') { + final parts = _chatData!['participants'] as Map? ?? {}; + final admins = _chatData!['adminParticipants'] as Map? ?? {}; + final owner = _chatData!['owner'] as int?; + + final memberIds = []; + for (final k in parts.keys) { + final id = k is int ? k : int.tryParse(k.toString()); + if (id != null) memberIds.add(id); + } + + final Map presenceMap = {}; + if (memberIds.isNotEmpty) { + final pp = await main.api.sendRequest( + Opcode.contactPresence, + {'contactIds': memberIds}, + ); + if (pp.isOk) { + final presence = (pp.payload as Map?)?['presence'] as Map?; + if (presence != null) { + for (final e in presence.entries) { + final id = e.key is int ? e.key as int : int.tryParse(e.key.toString()); + if (id != null && e.value is Map) presenceMap[id] = e.value as Map; + } + } + } + } + + _onlineCount = 0; + _members = memberIds.map((id) { + final pres = presenceMap[id]; + final online = ((pres?['status'] as int?) ?? 0) > 0; + if (online) _onlineCount++; + final isAdmin = + admins.containsKey(id.toString()) || admins.containsKey(id); + return _MemberInfo( + id: id, + isAdmin: isAdmin, + isOwner: id == owner, + isMe: id == _myId, + seenTime: pres?['seen'] as int?, + isOnline: online, + ); + }).toList(); + + _members.sort((a, b) { + if (a.isMe != b.isMe) return a.isMe ? -1 : 1; + if (a.isOnline != b.isOnline) return a.isOnline ? -1 : 1; + return (b.seenTime ?? 0).compareTo(a.seenTime ?? 0); + }); + } + + if (mounted) setState(() => _isLoading = false); } + // ─── BUILD ─────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context); + final isDark = cs.brightness == Brightness.dark; + final bg = isDark ? Colors.black : cs.surface; return Scaffold( - backgroundColor: cs.surface, - appBar: AppBar( - backgroundColor: cs.surface, - elevation: 0, - leading: IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: () => Navigator.pop(context), - ), - title: Text( - l10n?.chatInfoTitle ?? 'Info', - style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), - ), + backgroundColor: bg, + body: SafeArea( + child: _isLoading ? _buildShimmer(cs) : _buildScrollBody(cs), ), - body: _isLoading - ? _buildShimmer(cs) - : _chatData == null - ? Center( - child: Text( - 'No data', - style: TextStyle(color: cs.onSurfaceVariant), - ), - ) - : _buildContent(cs, l10n), ); } - Widget _buildShimmer(ColorScheme cs) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Center( - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - shape: BoxShape.circle, - ), + Widget _buildScrollBody(ColorScheme cs) { + return CustomScrollView( + slivers: [ + SliverAppBar( + backgroundColor: Colors.transparent, + elevation: 0, + floating: true, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), ), - ), - const SizedBox(height: 12), - Center( - child: Container( - width: 120, - height: 20, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - ), - ), - const SizedBox(height: 24), - ...List.generate( - 10, - (_) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Container( - height: 48, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), + actions: [ + if (widget.chatType == 'DIALOG' && !_isBot) + IconButton( + icon: Icon(Symbols.edit, color: cs.onSurface), + onPressed: () {}, ), - ), - ), + ], ), + SliverToBoxAdapter(child: _buildBody(cs)), ], ); } - Widget _buildContent(ColorScheme cs, AppLocalizations? l10n) { - final chat = _chatData!; - final type = chat['type'] as String? ?? ''; - - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Center( - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.primaryContainer, - ), - child: widget.imageUrl.isNotEmpty - ? ClipOval( - child: Image.network(widget.imageUrl, fit: BoxFit.cover), - ) - : Center( - child: Text( - widget.name.isNotEmpty - ? widget.name[0].toUpperCase() - : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 28, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - const SizedBox(height: 12), - Center( - child: Text( + Widget _buildBody(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const SizedBox(height: 4), + _buildAvatar(cs), + const SizedBox(height: 14), + Text( widget.name, style: TextStyle( color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, + fontSize: 22, + fontWeight: FontWeight.w700, ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + _subtitle(), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + _buildActions(cs), + const SizedBox(height: 16), + LayoutBuilder( + builder: (ctx, constraints) => + _buildInfoArea(cs, constraints.maxWidth), + ), + const SizedBox(height: 80), + ], + ), + ); + } + + // ─── AVATAR ────────────────────────────────────────────────────────────── + + Widget _buildAvatar(ColorScheme cs) { + return Container( + width: 96, + height: 96, + decoration: BoxDecoration(shape: BoxShape.circle, color: cs.primaryContainer), + child: widget.imageUrl.isNotEmpty + ? ClipOval( + child: CachedNetworkImage( + imageUrl: widget.imageUrl, + fit: BoxFit.cover, + errorWidget: (context, error, stack) => _avatarLetters(cs), + ), + ) + : _avatarLetters(cs), + ); + } + + Widget _avatarLetters(ColorScheme cs) => Center( + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 36, + fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 24), + ); - if (type == 'CHANNEL') ...[ - _buildSectionTitle('Channel', cs), - _buildRow( - l10n?.chatInfoSubscribers ?? 'subscribers:', - (chat['participantsCount'] as int?)?.toString() ?? '-', - cs, - ), - if ((chat['link'] as String?)?.isNotEmpty ?? false) - _buildRow( - l10n?.chatInfoLink ?? 'link:', - chat['link'] as String, - cs, - ), - _buildRow( - l10n?.chatInfoOfficial ?? 'official:', - (chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-', - cs, - ), - _buildRow( - l10n?.chatInfoComments ?? 'comments:', - (chat['options']?['COMMENTS'] as bool?)?.toString() ?? '-', - cs, - ), - _buildRow( - l10n?.chatInfoAplus ?? 'approved by Roskomnadzor:', - (chat['options']?['A_PLUS_CHANNEL'] as bool?)?.toString() ?? '-', - cs, - ), - _buildRow( - l10n?.chatInfoSignAdmin ?? 'admin signature:', - (chat['options']?['SIGN_ADMIN'] as bool?)?.toString() ?? '-', - cs, - ), - if ((chat['modified'] as int?) != null) - _buildRow( - l10n?.chatInfoLastChanged ?? 'last changed:', - _formatTs(chat['modified'] as int), - cs, - ), - if ((chat['created'] as int?) != null) - _buildRow( - l10n?.chatInfoCreated ?? 'created:', - _formatTs(chat['created'] as int), - cs, - ), + // ─── SUBTITLE ──────────────────────────────────────────────────────────── + + String _subtitle() { + switch (widget.chatType) { + case 'DIALOG': + if (_isBot) { + final link = _contactData?['link'] as String?; + final handle = link != null ? '@${Uri.parse(link).pathSegments.last}' : ''; + return '$handle · Бот'.trim(); + } + if (_isOnline) return 'В сети'; + if (_seenTime != null) return _formatLastSeen(_seenTime!); + return ''; + case 'CHAT': + final total = + (_chatData?['participantsCount'] as int?) ?? _members.length; + if (_onlineCount > 0) return '$_onlineCount из $total в сети'; + return _pluralCount(total, 'участник', 'участника', 'участников'); + case 'CHANNEL': + final count = (_chatData?['participantsCount'] as int?) ?? 0; + return _pluralCount(count, 'подписчик', 'подписчика', 'подписчиков'); + default: + return ''; + } + } + + // ─── ACTION BUTTONS ────────────────────────────────────────────────────── + + Widget _buildActions(ColorScheme cs) { + final List<({IconData icon, String label})> btns; + + if (widget.chatType == 'DIALOG') { + if (_isBot) { + btns = [ + (icon: Symbols.chat_bubble, label: 'Цат'), + (icon: Symbols.notifications, label: 'Звук'), + (icon: Symbols.more_horiz, label: 'Ещё'), + ]; + } else { + btns = [ + (icon: Symbols.call, label: 'Звонок'), + (icon: Symbols.videocam, label: 'Видео'), + (icon: Symbols.notifications, label: 'Звук'), + (icon: Symbols.more_horiz, label: 'Ещё'), + ]; + } + } else { + btns = [ + (icon: Symbols.notifications, label: 'Звук'), + (icon: Symbols.search, label: 'Найти'), + (icon: Symbols.more_horiz, label: 'Ещё'), + ]; + } + + return Row( + children: [ + for (int i = 0; i < btns.length; i++) ...[ + _actionBtn(cs, btns[i].icon, btns[i].label), + if (i < btns.length - 1) const SizedBox(width: 8), ], - - if (type == 'CHAT') ...[ - _buildSectionTitle('Chat', cs), - _buildRow( - l10n?.chatInfoMembers ?? 'members:', - (chat['participantsCount'] as int?)?.toString() ?? '-', - cs, - ), - if ((chat['hasBots'] as bool?) ?? false) - _buildRow( - l10n?.chatInfoHasBots ?? 'has bots:', - 'true', - cs, - ), - if ((chat['blockedParticipantsCount'] as int?) != null && - chat['blockedParticipantsCount'] > 0) - _buildRow( - l10n?.chatInfoBlockedCount ?? 'blocked in group:', - (chat['blockedParticipantsCount'] as int).toString(), - cs, - ), - _buildRow( - l10n?.chatInfoOfficialStatus ?? 'official status:', - (chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-', - cs, - ), - if ((chat['modified'] as int?) != null) - _buildRow( - l10n?.chatInfoLastChanged ?? 'last changed:', - _formatTs(chat['modified'] as int), - cs, - ), - if ((chat['joinTime'] as int?) != null && chat['joinTime'] != 1) - _buildRow( - l10n?.chatInfoJoined ?? 'joined:', - _formatTs(chat['joinTime'] as int), - cs, - ), - if ((chat['created'] as int?) != null) - _buildRow( - l10n?.chatInfoGroupCreated ?? 'group created:', - _formatTs(chat['created'] as int), - cs, - ), - if ((chat['owner'] as int?) != null) - _buildRow( - l10n?.chatInfoGroupOwner ?? 'group owner:', - (chat['owner'] as int).toString(), - cs, - ), - ], - - if (type == 'DIALOG') ...[ - if ((chat['created'] as int?) != null && - chat['created'] != 0 && - chat['created'] != 1) - _buildRow( - l10n?.chatInfoDialogStarted ?? 'dialog started:', - _formatTs(chat['created'] as int), - cs, - ), - ], - - const SizedBox(height: 120), ], ); } - Widget _buildSectionTitle(String title, ColorScheme cs) { - return Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), - child: Text( - title, - style: TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, + Widget _actionBtn(ColorScheme cs, IconData icon, String label) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: const Color(0xFF007AFF), size: 24), + const SizedBox(height: 5), + Text(label, style: TextStyle(color: cs.onSurface, fontSize: 12)), + ], ), ), ); } - Widget _buildRow(String label, String value, ColorScheme cs) { + // ─── SECTIONS ──────────────────────────────────────────────────────────── + + List _buildSections(ColorScheme cs) { + switch (widget.chatType) { + case 'DIALOG': + return _dialogSections(cs); + case 'CHAT': + return _groupSections(cs); + case 'CHANNEL': + return _channelSections(cs); + default: + return [_attachmentsCard(cs)]; + } + } + + List _dialogSections(ColorScheme cs) { + final result = []; + + if (_isBot) { + final link = _contactData?['link'] as String?; + if (link != null) { + result + ..add(_linkCard(cs, link)) + ..add(const SizedBox(height: 8)); + } + } else { + final phone = _contactData?['phone']; + final phoneInt = phone is int ? phone : int.tryParse(phone?.toString() ?? ''); + if (phoneInt != null && phoneInt > 0) { + result + ..add(_infoCard(cs, 'Номер телефона', _formatPhone(phoneInt))) + ..add(const SizedBox(height: 8)); + } + } + + result.add(_attachmentsCard(cs)); + return result; + } + + List _groupSections(ColorScheme cs) { + return [ + _attachmentsCard(cs), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + 'УЧАСТНИКИ', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ), + Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + _memberAction(cs, Symbols.person_add, 'Добавить участника', () {}), + _listDivider(cs), + _memberAction(cs, Symbols.link, 'Пригласить по ссылке', () {}), + ..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]), + ], + ), + ), + ]; + } + + List _channelSections(ColorScheme cs) { + final result = []; + + final link = _chatData?['link'] as String?; + if (link != null) { + result + ..add(_linkCard(cs, link)) + ..add(const SizedBox(height: 8)); + } + + final desc = _chatData?['description'] as String?; + if (desc != null && desc.isNotEmpty) { + result + ..add(_descCard(cs, desc)) + ..add(const SizedBox(height: 8)); + } + + result.add(_attachmentsCard(cs)); + return result; + } + + // ─── CARD WIDGETS ──────────────────────────────────────────────────────── + + Widget _infoCard(ColorScheme cs, String label, String value) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + const SizedBox(height: 4), + Text(value, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500)), + ], + ), + ); + } + + Widget _linkCard(ColorScheme cs, String link) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Ссылка', + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + const SizedBox(height: 4), + Text(link, + style: const TextStyle( + color: Color(0xFF007AFF), fontSize: 15)), + ], + ), + ), + IconButton( + icon: const Icon(Symbols.share, + color: Color(0xFF007AFF), size: 22), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Symbols.qr_code, + color: Color(0xFF007AFF), size: 22), + onPressed: () {}, + ), + ], + ), + ); + } + + Widget _descCard(ColorScheme cs, String desc) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Text(desc, + style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4)), + ); + } + + Widget _attachmentsCard(ColorScheme cs) { return Container( - margin: const EdgeInsets.only(bottom: 1), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.photo_library, color: cs.onSurfaceVariant, size: 28), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Вложения', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500)), + Text('Фото, видео, файлы и ссылки', + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ], + ), + ), + Icon(Symbols.chevron_right, color: cs.onSurfaceVariant), + ], + ), + ); + } + + // ─── MEMBER LIST ───────────────────────────────────────────────────────── + + Widget _memberAction( + ColorScheme cs, IconData icon, String label, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(icon, color: const Color(0xFF007AFF), size: 26), + const SizedBox(width: 14), + Text(label, + style: TextStyle(color: cs.onSurface, fontSize: 16)), + ], + ), + ), + ); + } + + Widget _listDivider(ColorScheme cs) => Divider( + height: 1, + indent: 56, + endIndent: 0, + color: cs.outlineVariant.withValues(alpha: 0.3), + ); + + Widget _memberTile(ColorScheme cs, _MemberInfo member) { + final name = + ContactCache.get(member.id) ?? (member.isMe ? 'Вы' : '${member.id}'); + final avatar = ContactCache.getAvatar(member.id); + + final String sublabel; + if (member.isMe) { + sublabel = 'Вы'; + } else if (member.isOnline) { + sublabel = 'В сети'; + } else if (member.seenTime != null) { + sublabel = _formatLastSeen(member.seenTime!); + } else { + sublabel = 'Был(-а) недавно'; + } + + final String? roleLabel = + member.isOwner ? 'владелец' : (member.isAdmin ? 'Адмін' : null); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + (avatar != null && avatar.isNotEmpty) + ? CircleAvatar( + radius: 22, + backgroundImage: CachedNetworkImageProvider(avatar), + backgroundColor: cs.primaryContainer, + ) + : CircleAvatar( + radius: 22, + backgroundColor: cs.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, fontSize: 16), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500)), + Text(sublabel, + style: TextStyle( + color: cs.onSurfaceVariant, fontSize: 13)), + ], + ), + ), + if (roleLabel != null) + Text(roleLabel, + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ], + ), + ); + } + + // ─── INFO AREA ─────────────────────────────────────────────────────────── + + Widget _buildInfoArea(ColorScheme cs, double W) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Кнопка "Инфо" — всегда полная ширина + GestureDetector( + onTap: () => setState(() => _infoExpanded = !_infoExpanded), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.info, color: const Color(0xFF007AFF), size: 22), + const SizedBox(width: 12), + Text( + 'Инфо', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + AnimatedRotation( + turns: _infoExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 220), + child: Icon(Symbols.keyboard_arrow_down, + color: cs.onSurfaceVariant), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + // Контент: при закрытии — обычная колонка, при открытии — Row + AnimatedCrossFade( + duration: const Duration(milliseconds: 250), + sizeCurve: Curves.easeOut, + firstCurve: Curves.easeOut, + secondCurve: Curves.easeOut, + crossFadeState: _infoExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildSections(cs), + ), + secondChild: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildCompactSections(cs)), + const SizedBox(width: 8), + Expanded(child: _buildInfoPanelCard(cs)), + ], + ), + ), + ], + ); + } + + // Компактные карточки — левая колонка при открытом инфо + Widget _buildCompactSections(ColorScheme cs) { + final items = []; + + if (widget.chatType == 'DIALOG') { + if (_isBot) { + final link = _contactData?['link'] as String?; + if (link != null) items.add(_compactCard(cs, 'Ссылка', link)); + } else { + final phone = _contactData?['phone']; + final phoneInt = + phone is int ? phone : int.tryParse(phone?.toString() ?? ''); + if (phoneInt != null && phoneInt > 0) { + items.add(_compactCard(cs, 'Телефон', _formatPhone(phoneInt))); + } + } + } + + if (widget.chatType == 'CHANNEL') { + final desc = _chatData?['description'] as String?; + if (desc != null && desc.isNotEmpty) { + items.add(_compactCard( + cs, + 'Описание', + desc.length > 80 ? '${desc.substring(0, 80)}…' : desc, + )); + } + } + + if (widget.chatType == 'CHAT') { + final total = (_chatData?['participantsCount'] as int?) ?? _members.length; + items.add(_compactCard(cs, 'Участников', '$total')); + } + + items.add(const SizedBox(height: 8)); + items.add(_compactAttachments(cs)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (int i = 0; i < items.length; i++) ...[ + items[i], + if (i < items.length - 1 && items[i] is! SizedBox) + const SizedBox(height: 8), + ], + ], + ); + } + + Widget _compactCard(ColorScheme cs, String label, String value) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11)), + const SizedBox(height: 3), + Text(value, + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w500), + maxLines: 4, + overflow: TextOverflow.ellipsis), + ], + ), + ); + } + + Widget _compactAttachments(ColorScheme cs) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ + Icon(Symbols.photo_library, color: cs.onSurfaceVariant, size: 20), + const SizedBox(width: 8), Expanded( - flex: 2, - child: Text( - label, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 3, - child: Text( - value, - style: TextStyle( - color: cs.onSurface, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.end, - ), + child: Text('Вложения', + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w500)), ), + Icon(Symbols.chevron_right, color: cs.onSurfaceVariant, size: 18), ], ), ); } - String _formatTs(int ts) { - if (ts < 1000000000000) return ts.toString(); - final dt = DateTime.fromMillisecondsSinceEpoch(ts); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + // Инфо-панель — правая колонка при открытом инфо + Widget _buildInfoPanelCard(ColorScheme cs) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: _buildAllInfoRows(cs), + ); } -} \ No newline at end of file + + Widget _buildAllInfoRows(ColorScheme cs) { + final rows = <({String label, String value})>[]; + final chat = _chatData; + if (chat == null) { + return Text('Нет данных', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); + } + + void add(String label, dynamic val, {bool tsFormat = false}) { + if (val == null) return; + if (val is bool && !val) return; + String str; + if (tsFormat && val is int && val > 1) { + str = _formatTs(val); + } else if (val is bool) { + str = 'да'; + } else { + str = val.toString(); + } + if (str.isEmpty) return; + rows.add((label: label, value: str)); + } + + final type = widget.chatType; + add('ID чата', chat['id']); + + if (type == 'DIALOG') { + add('Создан', chat['created'], tsFormat: true); + add('Изменён', chat['modified'], tsFormat: true); + add('Статус', chat['status']); + } + + if (type == 'CHAT') { + add('Участников', chat['participantsCount']); + final owner = chat['owner'] as int?; + if (owner != null && owner != 0) { + add('Владелец', ContactCache.get(owner) ?? '$owner'); + } + add('Создана', chat['created'], tsFormat: true); + add('Вступил', (chat['joinTime'] as int?) != null && (chat['joinTime'] as int) > 1 + ? chat['joinTime'] : null, tsFormat: true); + add('Изменена', chat['modified'], tsFormat: true); + add('Есть боты', chat['hasBots'] as bool?); + final blocked = chat['blockedParticipantsCount'] as int?; + if (blocked != null && blocked > 0) add('Заблокировано', blocked); + final opts = chat['options'] as Map?; + add('Официальная', opts?['OFFICIAL'] as bool?); + add('Подпись адм.', opts?['SIGN_ADMIN'] as bool?); + add('Статус', chat['status']); + } + + if (type == 'CHANNEL') { + add('Подписчиков', chat['participantsCount']); + add('Создан', chat['created'], tsFormat: true); + add('Изменён', chat['modified'], tsFormat: true); + final opts = chat['options'] as Map?; + add('Официальный', opts?['OFFICIAL'] as bool?); + add('Комментарии', opts?['COMMENTS'] as bool?); + add('РКН', opts?['A_PLUS_CHANNEL'] as bool?); + add('Подпись адм.', opts?['SIGN_ADMIN'] as bool?); + add('Только адм.', opts?['ONLY_ADMIN_CAN_ADD_MEMBER'] as bool?); + add('Статус', chat['status']); + } + + if (rows.isEmpty) { + return Text('Нет данных', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < rows.length; i++) ...[ + _infoRow(cs, rows[i].label, rows[i].value), + if (i < rows.length - 1) + Divider( + height: 10, + color: cs.outlineVariant.withValues(alpha: 0.25)), + ], + ], + ); + } + + Widget _infoRow(ColorScheme cs, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10)), + Text(value, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500)), + ], + ), + ); + } + + // ─── SHIMMER ───────────────────────────────────────────────────────────── + + Widget _buildShimmer(ColorScheme cs) { + Widget block(double w, double h, {double r = 8}) => Container( + width: w, + height: h, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(r), + ), + ); + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 60, 16, 0), + children: [ + Center( + child: block(96, 96, r: 48)), + const SizedBox(height: 14), + Center(child: block(160, 22, r: 8)), + const SizedBox(height: 8), + Center(child: block(110, 16, r: 6)), + const SizedBox(height: 24), + block(double.infinity, 70, r: 14), + const SizedBox(height: 12), + block(double.infinity, 70, r: 14), + const SizedBox(height: 12), + block(double.infinity, 70, r: 14), + ], + ); + } + + // ─── HELPERS ───────────────────────────────────────────────────────────── + + String _formatLastSeen(int ms) { + final diff = DateTime.now().millisecondsSinceEpoch - ms; + if (diff < 60000) return 'только что'; + if (diff < 3600000) return '${diff ~/ 60000} мин назад'; + if (diff < 86400000) return '${diff ~/ 3600000} ч назад'; + if (diff < 604800000) return '${diff ~/ 86400000} д назад'; + return 'давно'; + } + + String _formatPhone(int phone) { + final s = phone.toString(); + if (s.length == 11 && s.startsWith('7')) { + return '+7 ${s.substring(1, 4)} ${s.substring(4, 7)}-' + '${s.substring(7, 9)}-${s.substring(9, 11)}'; + } + return '+$s'; + } + + String _formatTs(int ts) { + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + String _pluralCount(int n, String one, String few, String many) { + final mod100 = n % 100; + final mod10 = n % 10; + if (mod100 >= 11 && mod100 <= 14) return '$n $many'; + if (mod10 == 1) return '$n $one'; + if (mod10 >= 2 && mod10 <= 4) return '$n $few'; + return '$n $many'; + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 906dadc..13cf4f7 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -787,7 +787,7 @@ class _ChatListScreenState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 2), + padding: const EdgeInsets.fromLTRB(20, 6, 20, 3), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -933,7 +933,7 @@ class _ChatListScreenState extends State ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), child: Container( height: 44, decoration: BoxDecoration( diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e37aca3..e6c7c8c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -51,7 +52,6 @@ class _ChatScreenState extends State final GlobalKey _listKey = GlobalKey(); bool _hasText = false; bool _isLoading = true; - bool _isSending = false; bool _showAttachmentPanel = false; late AnimationController _shimmerController; List _messages = []; @@ -64,6 +64,7 @@ class _ChatScreenState extends State late final AnimationController _floatingDateAnimController; final Map _separatorKeys = {}; double _lastScrollOffset = 0; + String? _lastSentId; @override void initState() { @@ -87,10 +88,10 @@ class _ChatScreenState extends State final activeProfile = await AppDatabase.loadActiveProfile(); _myId = activeProfile?.id ?? 0; ChatsModule.getChat(_myId, widget.chatId).then((value) { - chat = value[0]; - }).catchError((error) { - - }); + if (mounted && value.isNotEmpty) { + setState(() { chat = value.first; }); + } + }).catchError((_) {}); final cachedRows = await AppDatabase.loadMessages( _myId, @@ -155,17 +156,29 @@ class _ChatScreenState extends State } } + String? _effectiveStatus(CachedMessage msg) { + if (msg.senderId != _myId) return null; + if (msg.status == 'sending' || msg.status == 'error') return msg.status; + final c = chat; + if (c == null) return 'sent'; + int otherReadTime = 0; + for (final entry in c.participants.entries) { + if (entry.key != _myId && entry.value > otherReadTime) { + otherReadTime = entry.value; + } + } + if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read'; + return 'sent'; + } + Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; - setState(() { - _isSending = true; - }); + final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; + final now = DateTime.now().millisecondsSinceEpoch; try { - final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; - final now = DateTime.now().millisecondsSinceEpoch; final tempMessage = CachedMessage( id: tempId, @@ -178,6 +191,7 @@ class _ChatScreenState extends State ); setState(() { + _lastSentId = tempId; _messages.add(tempMessage); _messageController.clear(); _hasText = false; @@ -189,13 +203,13 @@ class _ChatScreenState extends State _scrollToBottom(); - await messagesModule.sendMessage(_myId, widget.chatId, text); + final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); final index = _messages.indexWhere((m) => m.id == tempId); - if (index != -1) { + if (index != -1 && mounted) { setState(() { _messages[index] = CachedMessage( - id: tempId, + id: actualId.isNotEmpty ? actualId : tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, @@ -206,12 +220,21 @@ class _ChatScreenState extends State }); } } catch (e) { - debugPrint('Error sending message: $e'); Haptics.error(); - } finally { - setState(() { - _isSending = false; - }); + final index = _messages.indexWhere((m) => m.id == tempId); + if (index != -1 && mounted) { + setState(() { + _messages[index] = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'error', + ); + }); + } } } @@ -429,7 +452,7 @@ class _ChatScreenState extends State // TODO: Локализация // TODO: Cклонения - String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently"; + final String status = chat?.type == "CHAT" ? "${chat?.participants.length ?? 0} участников" : "last seen recently"; return Scaffold( backgroundColor: cs.surface, appBar: PreferredSize( @@ -505,7 +528,7 @@ class _ChatScreenState extends State ], ), Text( - status ?? "", + status, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 12, @@ -590,8 +613,6 @@ class _ChatScreenState extends State final msgItem = item as _MessageItem; final message = msgItem.message; final msgIndex = msgItem.index; - debugPrint( - 'LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}'); final isMe = message.senderId == _myId; final prevMessage = msgIndex > 0 ? _messages[msgIndex - 1] : null; @@ -599,14 +620,26 @@ class _ChatScreenState extends State ? _messages[msgIndex + 1] : null; - return MessageBubble( + final bubble = MessageBubble( message: message, isMe: isMe, myId: _myId, prevMessage: prevMessage, nextMessage: nextMessage, chatType: chat?.type ?? 'CHAT', + overrideStatus: _effectiveStatus(message), ); + + if (isMe && message.id == _lastSentId) { + return _SentMessageAnimation( + key: ValueKey('anim_${message.id}'), + onComplete: () { + if (mounted) setState(() => _lastSentId = null); + }, + child: bubble, + ); + } + return bubble; }, ), if (_lastFloatingDate != null) @@ -805,22 +838,33 @@ class _ChatScreenState extends State Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400), const SizedBox(width: 12), Expanded( - child: TextField( - controller: _messageController, - style: TextStyle(color: cs.onSurface, fontSize: 16), - maxLines: null, - keyboardType: TextInputType.multiline, - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - hintText: 'Message', - hintStyle: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 16, - ), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - vertical: 14, + child: Focus( + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter && + !HardwareKeyboard.instance.isShiftPressed) { + if (_hasText) _sendMessage(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: _messageController, + style: TextStyle(color: cs.onSurface, fontSize: 16), + maxLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hintText: 'Message', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + ), ), ), ), @@ -892,3 +936,59 @@ class _ChatScreenState extends State ); } } + +class _SentMessageAnimation extends StatefulWidget { + final Widget child; + final VoidCallback onComplete; + + const _SentMessageAnimation({ + super.key, + required this.child, + required this.onComplete, + }); + + @override + State<_SentMessageAnimation> createState() => _SentMessageAnimationState(); +} + +class _SentMessageAnimationState extends State<_SentMessageAnimation> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _opacity; + late final Animation _slide; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + ); + _opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut); + _slide = Tween(begin: 16, end: 0).animate( + CurvedAnimation(parent: _ctrl, curve: Curves.easeOut), + ); + _ctrl.forward().whenComplete(widget.onComplete); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (context, child) => Opacity( + opacity: _opacity.value, + child: Transform.translate( + offset: Offset(0, _slide.value), + child: child, + ), + ), + child: widget.child, + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index fdbe793..c723f53 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -44,6 +44,7 @@ class MessageBubble extends StatelessWidget { final CachedMessage? prevMessage; final CachedMessage? nextMessage; final String chatType; + final String? overrideStatus; const MessageBubble({ super.key, @@ -52,7 +53,8 @@ class MessageBubble extends StatelessWidget { required this.myId, this.prevMessage, this.nextMessage, - required this.chatType + required this.chatType, + this.overrideStatus, }); bool get isGroupedWithNext { @@ -315,7 +317,6 @@ class MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { if (message.isControl) { - debugPrint('BUILD CONTROL: ${message.id}'); return Padding( padding: EdgeInsets.only(top: topMargin, bottom: bottomMargin), child: Center(child: _buildControlContent(context)), @@ -490,7 +491,7 @@ class MessageBubble extends StatelessWidget { children: [ Flexible( child: isForwarded - ? _buildForwardedInlineText(context, forwarded, textColor) + ? _buildForwardedInlineText(context, forwarded!, textColor) : Text( message.text ?? '', style: TextStyle(color: textColor, fontSize: 16, height: 1.3), @@ -500,7 +501,9 @@ class MessageBubble extends StatelessWidget { Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( - _formatTime(message.time), + message.status == 'EDITED' + ? '${_formatTime(message.time)} ред.' + : _formatTime(message.time), style: TextStyle( color: textColor.withValues(alpha: 0.7), fontSize: 10, @@ -904,13 +907,6 @@ class MessageBubble extends StatelessWidget { ForwardedMessageAttachment forwarded, List attachments, ) { - debugPrint('DEBUG: _buildForwardedGenericContent called'); - debugPrint('DEBUG: attachments count: ${attachments.length}'); - for (var i = 0; i < attachments.length; i++) { - debugPrint('DEBUG: attachment[$i] type: ${attachments[i].runtimeType}'); - debugPrint('DEBUG: attachment[$i] type field: ${attachments[i].type}'); - } - final cs = Theme.of(context).colorScheme; final headerColor = isMe ? Colors.white.withValues(alpha: 0.7) @@ -1671,7 +1667,7 @@ class MessageBubble extends StatelessWidget { url: url, textColor: textColor, isMe: isMe, - status: message.status, + status: overrideStatus ?? message.status, time: message.time, cs: cs, waveData: waveData, @@ -1729,31 +1725,31 @@ class MessageBubble extends StatelessWidget { } Widget _buildStatusIcon(BuildContext context) { - final status = message.status; + final status = overrideStatus ?? message.status; IconData icon; Color color; - if (status == null || status == 'sending' || status == 'pending') { - icon = Symbols.check; - color = Colors.white70; - } else { - switch (status) { - case 'sent': - icon = Symbols.check; - color = Colors.white70; - case 'delivered': - icon = Symbols.done_all; - color = Colors.white70; - case 'read': - icon = Symbols.done_all; - color = const Color(0xFF34C759); - case 'error': - icon = Symbols.error; - color = Colors.redAccent; - default: - icon = Symbols.check; - color = Colors.white70; - } + switch (status) { + case 'sending': + case 'pending': + icon = Symbols.schedule; + color = Colors.white70; + case null: + case 'sent': + icon = Symbols.check; + color = Colors.white70; + case 'delivered': + icon = Symbols.done_all; + color = Colors.white70; + case 'read': + icon = Symbols.done_all; + color = const Color(0xFF4FC3F7); + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = Colors.white70; } return Icon(icon, size: 14, color: color); @@ -1840,11 +1836,15 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { IconData icon; Color color; - if (status == null || status == 'sending' || status == 'pending') { + if (status == null || status == 'sent') { icon = Symbols.check; color = Colors.white54; } else { switch (status) { + case 'sending': + case 'pending': + icon = Symbols.schedule; + color = Colors.white54; case 'sent': icon = Symbols.check; color = Colors.white54; @@ -1853,7 +1853,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { color = Colors.white54; case 'read': icon = Symbols.done_all; - color = const Color(0xFF34C759); + color = const Color(0xFF4FC3F7); case 'error': icon = Symbols.error; color = Colors.redAccent; From 849df837202e1fab059c73a66f88dc9180b51088 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 15 May 2026 19:14:29 +0700 Subject: [PATCH 103/323] =?UTF-8?q?=D0=B3=D0=B5=D0=BF=D1=8B(=D0=B2=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5,=20=D1=85=D0=B7),=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D0=BB=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D1=83=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D1=81=D1=82=D0=B8,?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=BD=D1=8E=D1=88=D0=BA=D1=83=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B8=20=D1=82=D0=B0=D0=BC=20=D0=B1?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B8=D0=B4=D0=B8=20=D0=BD=D0=B0=D1=85=D1=83?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 11 ++++++++--- lib/frontend/screens/chats/chat_list_screen.dart | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 1a983be..e78828a 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -375,9 +375,7 @@ class AccountModule { final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { final saved = await AppDatabase.getPrivacyConfig(accountId); - if (saved != null) { - return PrivacyConfig.fromJson(saved); - } + if (saved != null) return PrivacyConfig.fromJson(saved); } return PrivacyConfig.empty(); } @@ -1021,6 +1019,13 @@ class AccountModule { profile.id, config.cast(), ); + final userConfig = config['user']; + if (userConfig is Map) { + await AppDatabase.savePrivacyConfig( + profile.id, + jsonEncode(userConfig), + ); + } } try { await FoldersModule.syncFromServer(_api, profile.id); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 13cf4f7..db596fd 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -933,7 +933,7 @@ class _ChatListScreenState extends State ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 6), child: Container( height: 44, decoration: BoxDecoration( @@ -1078,6 +1078,7 @@ class _ChatListScreenState extends State parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ + const SliverToBoxAdapter(child: SizedBox(height: 6)), if (chats.isEmpty && !_isInitialLoading) SliverFillRemaining( child: Center( From 52f74a51b5fa88142d4763b4b87522f5147b47d1 Mon Sep 17 00:00:00 2001 From: noxzion Date: Fri, 15 May 2026 17:51:01 +0500 Subject: [PATCH 104/323] =?UTF-8?q?=D0=B3=D0=BE=D0=B2=D0=BD=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 78 ++++++++++--------- pubspec.lock | 8 +- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index db596fd..d716833 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -933,7 +933,7 @@ class _ChatListScreenState extends State ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 6), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 14), child: Container( height: 44, decoration: BoxDecoration( @@ -1078,7 +1078,7 @@ class _ChatListScreenState extends State parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ - const SliverToBoxAdapter(child: SizedBox(height: 6)), + const SliverToBoxAdapter(child: SizedBox(height: 14)), if (chats.isEmpty && !_isInitialLoading) SliverFillRemaining( child: Center( @@ -1104,7 +1104,7 @@ class _ChatListScreenState extends State // Insert separator row between pinned and regular sections if (hasSeparator && index == pinnedCount) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 20), child: Divider( height: 1, thickness: 0.5, @@ -1823,7 +1823,7 @@ Navigator.push( ? cs.primary.withValues(alpha: 0.08) : Colors.transparent, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -2073,13 +2073,27 @@ Navigator.push( } Widget _buildFabMenu() { + final cs = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _buildFabMenuItem(Symbols.group_add, 'Создать группу'), + const SizedBox(height: 4), + _buildFabMenuItem(Symbols.campaign, 'Создать канал'), + const SizedBox(height: 4), + _buildFabMenuItem(Symbols.person_add, 'Создать контакт'), + ], + ); + } + + Widget _buildFabMenuItem(IconData icon, String title) { final cs = Theme.of(context).colorScheme; return Container( width: 220, - padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(100), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.2), @@ -2088,39 +2102,27 @@ Navigator.push( ), ], ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFabMenuItem(Symbols.search, 'Найти по номеру'), - _buildFabMenuItem(Symbols.group_add, 'Добавить группу'), - _buildFabMenuItem(Symbols.campaign, 'Создать канал'), - ], - ), - ); - } - - Widget _buildFabMenuItem(IconData icon, String title) { - final cs = Theme.of(context).colorScheme; - return InkWell( - onTap: () { - // Action logic here - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - Icon(icon, color: cs.onSurface, size: 22), - const SizedBox(width: 12), - Text( - title, - style: TextStyle( - color: cs.onSurface, - fontSize: 14, - fontWeight: FontWeight.w500, + child: InkWell( + onTap: () { + // Action logic here + }, + borderRadius: BorderRadius.circular(100), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 22), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), - ), - ], + ], + ), ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 00c6969..13dcd14 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -385,10 +385,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -726,10 +726,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" timezone: dependency: "direct main" description: From 5a6cb10c91ed0d8c05158afe5dd0177e0decd71f Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 15 May 2026 18:14:22 +0300 Subject: [PATCH 105/323] =?UTF-8?q?feat(push):=20FCM=20via=20MAX=20server?= =?UTF-8?q?=20=E2=80=94=20oneme=20flavor,=20dedicated=20FCM=20workflow,=20?= =?UTF-8?q?opcode=2022,=20notifications=20+=20CI/lint=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-android-fcm.yml | 91 +++++++++ .github/workflows/build-android.yml | 18 +- .github/workflows/flutter-dev.yml | 17 +- .github/workflows/flutter-main.yml | 78 +++++++- android/app/build.gradle.kts | 20 +- android/app/src/komet/google-services.json | 29 +++ android/app/src/main/AndroidManifest.xml | 4 + android/app/src/oneme/google-services.json | 29 +++ android/settings.gradle.kts | 1 + lib/backend/modules/account.dart | 33 +++ lib/core/push/push_service.dart | 188 ++++++++++++++++++ .../screens/chats/chat_list_screen.dart | 7 +- lib/frontend/screens/chats/chat_screen.dart | 1 - .../screens/profile/edit_profile_screen.dart | 3 +- .../profile/password_entry_screen.dart | 2 - .../screens/profile/security_screen.dart | 2 +- lib/frontend/widgets/message_bubble.dart | 178 ++--------------- lib/main.dart | 18 ++ pubspec.lock | 128 +++++++++++- pubspec.yaml | 3 + 20 files changed, 647 insertions(+), 203 deletions(-) create mode 100644 .github/workflows/build-android-fcm.yml create mode 100644 android/app/src/komet/google-services.json create mode 100644 android/app/src/oneme/google-services.json create mode 100644 lib/core/push/push_service.dart diff --git a/.github/workflows/build-android-fcm.yml b/.github/workflows/build-android-fcm.yml new file mode 100644 index 0000000..b603622 --- /dev/null +++ b/.github/workflows/build-android-fcm.yml @@ -0,0 +1,91 @@ +name: Build Android (FCM) + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'lib/**' + - 'android/**' + - 'pubspec.yaml' + - '.github/workflows/build-android-fcm.yml' + pull_request: + paths: + - 'lib/**' + - 'android/**' + - 'pubspec.yaml' + - '.github/workflows/build-android-fcm.yml' + +jobs: + build-android-fcm: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v3 + + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + channel: 'stable' + + - name: Get dependencies + run: flutter pub get + + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties + + - name: Build Universal APK + run: flutter build apk --release --flavor oneme + + - name: Build Split APKs + run: flutter build apk --release --split-per-abi --flavor oneme + + - name: Upload Universal APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-fcm-universal + path: build/app/outputs/flutter-apk/app-oneme-release.apk + retention-days: 30 + + - name: Upload arm64-v8a APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-fcm-arm64-v8a + path: build/app/outputs/flutter-apk/app-oneme-arm64-v8a-release.apk + retention-days: 30 + + - name: Upload armeabi-v7a APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-fcm-armeabi-v7a + path: build/app/outputs/flutter-apk/app-oneme-armeabi-v7a-release.apk + retention-days: 30 + + - name: Upload x86_64 APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-fcm-x86_64 + path: build/app/outputs/flutter-apk/app-oneme-x86_64-release.apk + retention-days: 30 + + - name: Build App Bundle + run: flutter build appbundle --release --flavor oneme + + - name: Upload App Bundle artifact + uses: actions/upload-artifact@v4 + with: + name: komet-android-fcm-aab + path: build/app/outputs/bundle/onemeRelease/app-oneme-release.aab + retention-days: 30 diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 380cd38..4866fe6 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -45,47 +45,47 @@ jobs: mkdir -p ~/.gradle echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties - + - name: Build Universal APK - run: flutter build apk --release + run: flutter build apk --release --flavor komet - name: Build Split APKs - run: flutter build apk --release --split-per-abi + run: flutter build apk --release --split-per-abi --flavor komet - name: Upload Universal APK uses: actions/upload-artifact@v4 with: name: komet-android-universal - path: build/app/outputs/flutter-apk/app-release.apk + path: build/app/outputs/flutter-apk/app-komet-release.apk retention-days: 30 - name: Upload arm64-v8a APK uses: actions/upload-artifact@v4 with: name: komet-android-arm64-v8a - path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk + path: build/app/outputs/flutter-apk/app-komet-arm64-v8a-release.apk retention-days: 30 - name: Upload armeabi-v7a APK uses: actions/upload-artifact@v4 with: name: komet-android-armeabi-v7a - path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk + path: build/app/outputs/flutter-apk/app-komet-armeabi-v7a-release.apk retention-days: 30 - name: Upload x86_64 APK uses: actions/upload-artifact@v4 with: name: komet-android-x86_64 - path: build/app/outputs/flutter-apk/app-x86_64-release.apk + path: build/app/outputs/flutter-apk/app-komet-x86_64-release.apk retention-days: 30 - name: Build App Bundle - run: flutter build appbundle --release + run: flutter build appbundle --release --flavor komet - name: Upload App Bundle artifact uses: actions/upload-artifact@v4 with: name: komet-android-aab - path: build/app/outputs/bundle/release/app-release.aab + path: build/app/outputs/bundle/kometRelease/app-komet-release.aab retention-days: 30 diff --git a/.github/workflows/flutter-dev.yml b/.github/workflows/flutter-dev.yml index a1dc487..7e5d633 100644 --- a/.github/workflows/flutter-dev.yml +++ b/.github/workflows/flutter-dev.yml @@ -13,10 +13,17 @@ jobs: - name: Checkout code uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: 'stable' + flutter-version: '3.41.5' + channel: 'stable' - name: Install dependencies run: flutter pub get @@ -24,5 +31,11 @@ jobs: - name: Flutter analyze run: flutter analyze + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties + - name: Build Android APK - run: flutter build apk --release + run: flutter build apk --release --flavor komet diff --git a/.github/workflows/flutter-main.yml b/.github/workflows/flutter-main.yml index 3255616..f43dd96 100644 --- a/.github/workflows/flutter-main.yml +++ b/.github/workflows/flutter-main.yml @@ -6,17 +6,23 @@ on: - 'main' jobs: - analyze-and-build-all: + android: runs-on: ubuntu-latest - steps: - name: Checkout code uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: 'stable' + flutter-version: '3.41.5' + channel: 'stable' - name: Install dependencies run: flutter pub get @@ -24,8 +30,34 @@ jobs: - name: Flutter analyze run: flutter analyze + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties + - name: Build Android APK - run: flutter build apk --release + run: flutter build apk --release --flavor komet + + web-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + channel: 'stable' + + - name: Install Linux desktop dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev + + - name: Install dependencies + run: flutter pub get - name: Build Web run: flutter build web --release @@ -33,13 +65,41 @@ jobs: - name: Build Linux run: flutter build linux --release + windows: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + - name: Build Windows run: flutter build windows --release - - name: Build iOS (only macOS runners) - if: runner.os == 'macOS' - run: flutter build ios --release + apple: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 - - name: Build macOS (only macOS runners) - if: runner.os == 'macOS' + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + - name: Build iOS + run: flutter build ios --release --no-codesign + + - name: Build macOS run: flutter build macos --release diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1e361bc..fa4342c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") } android { @@ -20,16 +21,27 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "ru.komet.app" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = maxOf(flutter.minSdkVersion, 23) targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + flavorDimensions += "distribution" + + productFlavors { + create("komet") { + dimension = "distribution" + isDefault = true + applicationId = "ru.komet.app" + } + create("oneme") { + dimension = "distribution" + applicationId = "ru.oneme.app" + } + } + buildTypes { release { // TODO: Add your own signing config for the release build. diff --git a/android/app/src/komet/google-services.json b/android/app/src/komet/google-services.json new file mode 100644 index 0000000..9027e06 --- /dev/null +++ b/android/app/src/komet/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "659634599081", + "project_id": "max-messenger-app", + "storage_bucket": "max-messenger-app.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:659634599081:android:00000000000000000000ab", + "android_client_info": { + "package_name": "ru.komet.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyABuDYeeDXIOrKTXLkUj30Ii143ofPe63Q" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b2c538d..8dd5624 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + + +## Описание + + + +**Объём:** N файлов, +X / −Y. + +## Что вошло + + + +### 💬 Чаты и сообщения +- + +### 👤 Профиль и контакты +- + +### 🔒 Безопасность + +- + +### 🛠 Прочее +- + +## Коммиты + + +- `xxxxxxx` + +## Проверка + +- [ ] `flutter analyze` +- [ ] Сборка `komet` flavor +- [ ] Ручная проверка затронутых сценариев +- [ ] Регресс по безопасности (если затронута) diff --git a/lib/core/config/app_message_actions_style.dart b/lib/core/config/app_message_actions_style.dart new file mode 100644 index 0000000..2b8c5ef --- /dev/null +++ b/lib/core/config/app_message_actions_style.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum MessageActionsStyle { radial, list } + +class AppMessageActionsStyle { + static const prefKey = 'app_message_actions_style'; + static final ValueNotifier current = ValueNotifier( + MessageActionsStyle.radial, + ); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return _parse(prefs.getString(prefKey)); + } + + static Future save(MessageActionsStyle style) async { + current.value = style; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, style.name); + } + + static MessageActionsStyle _parse(String? val) { + if (val == MessageActionsStyle.list.name) return MessageActionsStyle.list; + return MessageActionsStyle.radial; + } + + static String label(MessageActionsStyle style) { + switch (style) { + case MessageActionsStyle.radial: + return 'Радиальное'; + case MessageActionsStyle.list: + return 'Список'; + } + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 3fb8bdb..96884ff 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -19,6 +19,7 @@ import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; +import '../../../core/config/app_message_actions_style.dart'; import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; import '../../widgets/message_actions_overlay.dart'; @@ -1745,6 +1746,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { isMe: widget.isMe, messageText: widget.message.text, controller: controller, + style: AppMessageActionsStyle.current.value, onDispose: () { if (identical(_controller, controller)) { _controller = null; diff --git a/lib/frontend/screens/profile/customization_screen.dart b/lib/frontend/screens/profile/customization_screen.dart index ff447a7..aca2378 100644 --- a/lib/frontend/screens/profile/customization_screen.dart +++ b/lib/frontend/screens/profile/customization_screen.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../core/utils/haptics.dart'; import 'appearance_screen.dart'; import 'font_settings_screen.dart'; +import 'message_actions_screen.dart'; import 'theme_settings_screen.dart'; class _CustomizationCategory { @@ -43,6 +44,12 @@ class CustomizationScreen extends StatelessWidget { subtitle: 'Шрифт приложения, свои шрифты, размер текста', builder: _buildFontSettings, ), + _CustomizationCategory( + icon: Symbols.touch_app, + title: 'Меню действий', + subtitle: 'Радиальное или список — для долгого нажатия на сообщение', + builder: _buildMessageActions, + ), ]; static Widget _buildAppearance(BuildContext context) => @@ -54,6 +61,9 @@ class CustomizationScreen extends StatelessWidget { static Widget _buildThemeSettings(BuildContext context) => const ThemeSettingsScreen(); + static Widget _buildMessageActions(BuildContext context) => + const MessageActionsScreen(); + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; diff --git a/lib/frontend/screens/profile/message_actions_screen.dart b/lib/frontend/screens/profile/message_actions_screen.dart new file mode 100644 index 0000000..c0abf82 --- /dev/null +++ b/lib/frontend/screens/profile/message_actions_screen.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/app_message_actions_style.dart'; +import '../../../core/utils/haptics.dart'; + +class MessageActionsScreen extends StatelessWidget { + const MessageActionsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBarM3E( + titleText: 'Меню действий', + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: const [ + _StyleCard(), + ], + ), + ), + ); + } +} + +class _StyleCard extends StatelessWidget { + const _StyleCard(); + + static const _items = [ + ( + style: MessageActionsStyle.radial, + icon: Symbols.bubble_chart, + label: 'Радиальное', + description: 'Дуга кнопок вокруг точки нажатия', + ), + ( + style: MessageActionsStyle.list, + icon: Symbols.menu, + label: 'Список', + description: 'Вертикальное меню рядом с сообщением', + ), + ]; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Стиль', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Как показывается меню при долгом нажатии на сообщение', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: AppMessageActionsStyle.current, + builder: (context, current, _) { + return Column( + children: [ + for (final item in _items) + _StyleTile( + icon: item.icon, + label: item.label, + description: item.description, + selected: current == item.style, + onTap: () { + if (current == item.style) return; + Haptics.selection(); + AppMessageActionsStyle.save(item.style); + }, + ), + ], + ); + }, + ), + ], + ), + ), + ); + } +} + +class _StyleTile extends StatelessWidget { + final IconData icon; + final String label; + final String description; + final bool selected; + final VoidCallback onTap; + + const _StyleTile({ + required this.icon, + required this.label, + required this.description, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + children: [ + Icon(icon, color: cs.onSurface, size: 22, weight: 500), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12.5, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + selected + ? Symbols.radio_button_checked + : Symbols.radio_button_unchecked, + color: selected ? cs.primary : cs.outline, + size: 22, + fill: selected ? 1 : 0, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 3335240..3d87892 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../core/config/app_message_actions_style.dart'; import '../../core/utils/haptics.dart'; import 'custom_notification.dart'; @@ -68,6 +69,7 @@ void showMessageActions({ required bool isMe, required String? messageText, required MessageActionsController controller, + required MessageActionsStyle style, required VoidCallback onDispose, }) { final overlay = Overlay.of(context, rootOverlay: true); @@ -80,6 +82,7 @@ void showMessageActions({ isMe: isMe, messageText: messageText, controller: controller, + style: style, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -96,6 +99,7 @@ class _MessageActionsLayer extends StatefulWidget { final bool isMe; final String? messageText; final MessageActionsController controller; + final MessageActionsStyle style; final VoidCallback onDismiss; const _MessageActionsLayer({ @@ -105,6 +109,7 @@ class _MessageActionsLayer extends StatefulWidget { required this.isMe, required this.messageText, required this.controller, + required this.style, required this.onDismiss, }); @@ -127,6 +132,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> bool _showBelow = true; Offset _anchor = Offset.zero; List _buttonCenters = const []; + List _buttonHitRects = const []; + Rect _menuRect = Rect.zero; bool _initialized = false; int _hoveredIndex = -1; @@ -155,15 +162,55 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _initialized = true; _actions = _buildActions(); final screenSize = MediaQuery.sizeOf(context); + if (widget.style == MessageActionsStyle.radial) { + _computeRadialGeometry(screenSize); + } else { + _computeListGeometry(screenSize); + } + widget.controller.addListener(_onControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _onControllerUpdate(); + }); + } + + void _computeRadialGeometry(Size screenSize) { _showBelow = widget.tapPoint.dy < screenSize.height * 0.55; _anchor = _showBelow ? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16) : Offset(widget.tapPoint.dx, widget.originRect.top - 16); _buttonCenters = _computeButtonCenters(screenSize); - widget.controller.addListener(_onControllerUpdate); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _onControllerUpdate(); - }); + _buttonHitRects = [ + for (final c in _buttonCenters) + Rect.fromCenter(center: c, width: _hitRadius * 2, height: _hitRadius * 2), + ]; + } + + void _computeListGeometry(Size screenSize) { + final n = _actions.length; + const menuWidth = 244.0; + const itemHeight = 48.0; + const vPad = 8.0; + final menuHeight = n * itemHeight + vPad * 2; + final spaceBelow = screenSize.height - widget.originRect.bottom - 24; + final spaceAbove = widget.originRect.top - 24; + _showBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove; + final menuY = _showBelow + ? widget.originRect.bottom + 10 + : widget.originRect.top - 10 - menuHeight; + final rawX = widget.isMe + ? widget.originRect.right - menuWidth + : widget.originRect.left; + final menuX = rawX.clamp(8.0, screenSize.width - menuWidth - 8.0).toDouble(); + _menuRect = Rect.fromLTWH(menuX, menuY, menuWidth, menuHeight); + _buttonHitRects = [ + for (int i = 0; i < n; i++) + Rect.fromLTWH( + menuX, + menuY + vPad + i * itemHeight, + menuWidth, + itemHeight, + ), + ]; } @override @@ -180,7 +227,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy), _Action(Symbols.reply, 'Ответить', () => _stub('Ответ')), _Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')), - _Action(Symbols.delete, 'Удалить', () => _stub('Удаление')), + _Action( + Symbols.delete, + 'Удалить', + () => _stub('Удаление'), + destructive: true, + ), ]; } @@ -223,10 +275,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> } int _findButtonAt(Offset p) { - for (int i = 0; i < _buttonCenters.length; i++) { - if ((_buttonCenters[i] - p).distance <= _hitRadius) { - return i; - } + for (int i = 0; i < _buttonHitRects.length; i++) { + if (_buttonHitRects[i].contains(p)) return i; } return -1; } @@ -307,8 +357,11 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ), ), ), - ..._buildButtons(t), - _buildLabelBanner(size, t), + if (widget.style == MessageActionsStyle.radial) ...[ + ..._buildButtons(t), + _buildLabelBanner(size, t), + ] else + _buildListMenu(t), ], ), ); @@ -316,6 +369,47 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } + Widget _buildListMenu(double t) { + final cs = Theme.of(context).colorScheme; + final eased = Curves.easeOutCubic.transform(t); + final scale = 0.88 + 0.12 * eased; + return Positioned( + left: _menuRect.left, + top: _menuRect.top, + width: _menuRect.width, + height: _menuRect.height, + child: Opacity( + opacity: eased, + child: Transform.scale( + scale: scale, + alignment: Alignment( + widget.isMe ? 1.0 : -1.0, + _showBelow ? -1.0 : 1.0, + ), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + clipBehavior: Clip.antiAlias, + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + for (int i = 0; i < _actions.length; i++) + _ListMenuItem( + action: _actions[i], + highlighted: _hoveredIndex == i, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ); + } + List _buildButtons(double t) { final n = _actions.length; return [ @@ -409,7 +503,63 @@ class _Action { final IconData icon; final String label; final VoidCallback onTap; - const _Action(this.icon, this.label, this.onTap); + final bool destructive; + const _Action( + this.icon, + this.label, + this.onTap, { + this.destructive = false, + }); +} + +class _ListMenuItem extends StatelessWidget { + final _Action action; + final bool highlighted; + const _ListMenuItem({required this.action, required this.highlighted}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final fg = action.destructive ? cs.error : cs.onSurface; + final hoverBg = action.destructive + ? cs.error.withValues(alpha: 0.14) + : cs.primary.withValues(alpha: 0.16); + return AnimatedContainer( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + height: 48, + color: highlighted ? hoverBg : Colors.transparent, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Haptics.tap(); + action.onTap(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon(action.icon, color: fg, size: 22), + const SizedBox(width: 14), + Expanded( + child: Text( + action.label, + style: TextStyle( + color: fg, + fontSize: 15, + fontWeight: + highlighted ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } } class _ActionButton extends StatelessWidget { diff --git a/lib/main.dart b/lib/main.dart index b871ce2..cbf9d76 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'core/config/app_bubble_behavior.dart'; import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; +import 'core/config/app_message_actions_style.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -90,6 +91,7 @@ void main() async { AppThemeModeConfig.current.value = await AppThemeModeConfig.load(); AppAmoled.current.value = await AppAmoled.load(); AppThemeSchedule.current.value = await AppThemeSchedule.load(); + AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load(); runApp( KometApp( initialLocale: initialLocale, From 96922e1f9ac35f3362ab97561fc35b376a65973a Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 23 May 2026 08:36:53 +0000 Subject: [PATCH 150/323] =?UTF-8?q?=D0=BF=D1=83=D0=BD=D0=BA=D1=82=D1=8B=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D1=87=D0=B8=D0=B2=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20primary-=D1=86=D0=B2=D0=B5=D1=82=D0=BE=D0=BC=20(destru?= =?UTF-8?q?ctive=20=E2=80=94=20error)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/message_actions_overlay.dart | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 3d87892..67d2a07 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -520,15 +520,12 @@ class _ListMenuItem extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final fg = action.destructive ? cs.error : cs.onSurface; - final hoverBg = action.destructive - ? cs.error.withValues(alpha: 0.14) - : cs.primary.withValues(alpha: 0.16); - return AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: Curves.easeOutCubic, + final pillBg = action.destructive ? cs.error : cs.primary; + final onPill = action.destructive ? cs.onError : cs.onPrimary; + final restFg = action.destructive ? cs.error : cs.onSurface; + final fg = highlighted ? onPill : restFg; + return SizedBox( height: 48, - color: highlighted ? hoverBg : Colors.transparent, child: Material( color: Colors.transparent, child: InkWell( @@ -537,23 +534,32 @@ class _ListMenuItem extends StatelessWidget { action.onTap(); }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Icon(action.icon, color: fg, size: 22), - const SizedBox(width: 14), - Expanded( - child: Text( - action.label, - style: TextStyle( - color: fg, - fontSize: 15, - fontWeight: - highlighted ? FontWeight.w600 : FontWeight.w500, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: highlighted ? pillBg : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + Icon(action.icon, color: fg, size: 22), + const SizedBox(width: 14), + Expanded( + child: Text( + action.label, + style: TextStyle( + color: fg, + fontSize: 15, + fontWeight: + highlighted ? FontWeight.w600 : FontWeight.w500, + ), ), ), - ), - ], + ], + ), ), ), ), From 071033f4d67e26ccf86dcad28646cd5c68e9b1e4 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Mon, 25 May 2026 00:23:45 +0700 Subject: [PATCH 151/323] =?UTF-8?q?=D0=B7=D0=B0=D0=B2=D1=82=D1=80=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B4=D0=B5=D0=BB=D0=B0=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/profile/settings_tab.dart | 38 +++ .../screens/profile/spoof_screen.dart | 89 +++++- lib/frontend/widgets/info_action_sheet.dart | 275 ++++++++++++++++++ lib/l10n/app_en.arb | 4 + lib/l10n/app_localizations.dart | 24 ++ lib/l10n/app_localizations_en.dart | 12 + lib/l10n/app_localizations_ru.dart | 12 + lib/l10n/app_ru.arb | 4 + 8 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 lib/frontend/widgets/info_action_sheet.dart diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 53eb24c..16300fe 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -9,6 +9,7 @@ import '../../../core/storage/token_storage.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../../widgets/info_action_sheet.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; import 'customization_screen.dart'; @@ -98,6 +99,38 @@ class _SettingsTabState extends State { if (mounted) setState(() => _hapticsEnabled = value); } + Future _openCloudStorage(BuildContext context) async { + final cs = Theme.of(context).colorScheme; + await showInfoActionSheet( + context, + headerIcon: Symbols.cloud, + title: 'Облачное хранилище', + subtitle: 'Через МАХ', + items: [ + const InfoActionSheetItem( + icon: Symbols.cloud_done, + title: 'Работает при белых списках', + body: 'Вы сможете передать файл даже при ограниченном интернете.', + ), + const InfoActionSheetItem( + icon: Symbols.inventory_2, + title: 'Файлы до 4ГБ, безлимитное количество.', + body: 'Можете хранить массивный обьем информации.', + ), + InfoActionSheetItem( + icon: Symbols.gpp_maybe, + title: 'Не обеспечивается конфединциальность файлов', + body: + 'Облачное хранилище работает через ваш аккаунт на сервере МАХ, ' + 'нужные люди всё равно могут его посмотреть.', + titleColor: cs.error, + ), + ], + confirmLabel: 'ОК', + confirmDelay: const Duration(seconds: 3), + ); + } + Future _confirmLogout() async { final cs = Theme.of(context).colorScheme; final confirmed = await showModalBottomSheet( @@ -277,6 +310,11 @@ child: _buildSection( toggleValue: _hapticsEnabled, onToggle: _setHaptics, ), + _SettingsItem( + icon: Symbols.cloud, + label: 'Облачное хранилище [BETA]', + onTap: () => _openCloudStorage(context), + ), _SettingsItem( icon: Symbols.vpn_lock, label: 'Прокси', diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index 1a1f724..9eef76d 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -8,10 +8,12 @@ import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../core/config/device_presets.dart'; +import '../../../core/storage/device_identity.dart'; import '../../../core/storage/spoofing_service.dart'; import '../../../core/storage/token_storage.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../../widgets/info_action_sheet.dart'; import '../auth/login_screen.dart'; enum SpoofingMethod { partial, full } @@ -36,6 +38,10 @@ class _SpoofScreenState extends State { final _deviceIdController = TextEditingController(); final _appVersionController = TextEditingController(); final _buildNumberController = TextEditingController(); + final _instanceIdController = TextEditingController(); + final _clientSessionIdController = TextEditingController(); + final _deviceLocaleController = TextEditingController(); + final _pushDeviceTypeController = TextEditingController(text: 'GCM'); String _selectedDeviceType = 'ANDROID'; String _selectedArch = 'arm64-v8a'; @@ -45,9 +51,34 @@ class _SpoofScreenState extends State { @override void initState() { super.initState(); + _localeController.addListener(_syncDeviceLocale); _loadInitialData(); } + void _syncDeviceLocale() { + if (_selectedMethod == SpoofingMethod.full) return; + final derived = _localeController.text.split(RegExp(r'[-_]')).first; + if (_deviceLocaleController.text != derived) { + _deviceLocaleController.text = derived; + } + } + + Future _confirmFullSpoofing() { + return showInfoActionSheet( + context, + headerIcon: Icons.warning_amber_rounded, + title: 'Могут быть последствия.', + subtitle: 'Меняй, только если знаешь что делаешь.', + confirmLabel: 'ОК', + confirmDelay: const Duration(seconds: 3), + ); + } + + Future _loadSessionIdentifiers() async { + _instanceIdController.text = await DeviceIdentity.instanceId(); + _clientSessionIdController.text = '${DeviceIdentity.clientSessionId}'; + } + String _generateDeviceId() { final bytes = List.generate(8, (_) => _random.nextInt(256)); return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); @@ -55,6 +86,7 @@ class _SpoofScreenState extends State { Future _loadInitialData() async { setState(() => _isLoading = true); + await _loadSessionIdentifiers(); final prefs = await SharedPreferences.getInstance(); final isSpoofingEnabled = prefs.getBool('spoofing_enabled') ?? false; @@ -336,6 +368,7 @@ class _SpoofScreenState extends State { @override void dispose() { + _localeController.removeListener(_syncDeviceLocale); _deviceNameController.dispose(); _osVersionController.dispose(); _screenController.dispose(); @@ -344,6 +377,10 @@ class _SpoofScreenState extends State { _deviceIdController.dispose(); _appVersionController.dispose(); _buildNumberController.dispose(); + _instanceIdController.dispose(); + _clientSessionIdController.dispose(); + _deviceLocaleController.dispose(); + _pushDeviceTypeController.dispose(); super.dispose(); } @@ -453,8 +490,16 @@ class _SpoofScreenState extends State { ), ], selected: {_selectedMethod}, - onSelectionChanged: (s) => - setState(() => _selectedMethod = s.first), + onSelectionChanged: (s) async { + final next = s.first; + if (next == _selectedMethod) return; + if (next == SpoofingMethod.full) { + final confirmed = await _confirmFullSpoofing(); + if (!confirmed || !mounted) return; + } + setState(() => _selectedMethod = next); + _syncDeviceLocale(); + }, ), const SizedBox(height: 12), descriptionWidget, @@ -604,6 +649,15 @@ class _SpoofScreenState extends State { Icons.language_outlined, ), ), + const SizedBox(height: 16), + TextField( + controller: _deviceLocaleController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldDeviceLocale, + Icons.translate_outlined, + ), + ), ], ), ), @@ -625,6 +679,24 @@ class _SpoofScreenState extends State { text: l10n.spoofIdentifiersDescription, ), const SizedBox(height: 12), + TextField( + controller: _instanceIdController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldInstanceId, + Icons.fingerprint_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _clientSessionIdController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldClientSessionId, + Icons.vpn_key_outlined, + ), + ), + const SizedBox(height: 16), TextField( controller: _deviceIdController, decoration: @@ -642,7 +714,7 @@ class _SpoofScreenState extends State { const SizedBox(height: 16), TextField( controller: _appVersionController, - enabled: false, + enabled: _selectedMethod == SpoofingMethod.full, decoration: _inputDecoration( l10n.spoofFieldAppVersion, Icons.info_outline_rounded, @@ -651,7 +723,7 @@ class _SpoofScreenState extends State { const SizedBox(height: 16), TextField( controller: _buildNumberController, - enabled: false, + enabled: _selectedMethod == SpoofingMethod.full, keyboardType: TextInputType.number, decoration: _inputDecoration( l10n.spoofFieldBuildNumber, @@ -659,6 +731,15 @@ class _SpoofScreenState extends State { ), ), const SizedBox(height: 16), + TextField( + controller: _pushDeviceTypeController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldPushDeviceType, + Icons.notifications_outlined, + ), + ), + const SizedBox(height: 16), Padding( padding: const EdgeInsets.only(left: 4, bottom: 8), child: Text( diff --git a/lib/frontend/widgets/info_action_sheet.dart b/lib/frontend/widgets/info_action_sheet.dart new file mode 100644 index 0000000..d6c6dce --- /dev/null +++ b/lib/frontend/widgets/info_action_sheet.dart @@ -0,0 +1,275 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class InfoActionSheetItem { + final IconData icon; + final String title; + final String body; + final Color? titleColor; + final Color? iconColor; + + const InfoActionSheetItem({ + required this.icon, + required this.title, + required this.body, + this.titleColor, + this.iconColor, + }); +} + +Future showInfoActionSheet( + BuildContext context, { + String? headerEmoji, + IconData? headerIcon, + required String title, + String? subtitle, + List items = const [], + String confirmLabel = 'ОК', + Duration confirmDelay = Duration.zero, + String? seenKey, +}) async { + assert( + headerEmoji == null || headerIcon == null, + 'Use either headerEmoji or headerIcon, not both.', + ); + + if (seenKey != null) { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(seenKey) ?? false) return true; + } + + if (!context.mounted) return false; + + final cs = Theme.of(context).colorScheme; + final confirmed = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => _InfoActionSheet( + headerEmoji: headerEmoji, + headerIcon: headerIcon, + title: title, + subtitle: subtitle, + items: items, + confirmLabel: confirmLabel, + confirmDelay: confirmDelay, + ), + ); + + final ok = confirmed == true; + if (ok && seenKey != null) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(seenKey, true); + } + return ok; +} + +class _InfoActionSheet extends StatefulWidget { + final String? headerEmoji; + final IconData? headerIcon; + final String title; + final String? subtitle; + final List items; + final String confirmLabel; + final Duration confirmDelay; + + const _InfoActionSheet({ + this.headerEmoji, + this.headerIcon, + required this.title, + this.subtitle, + this.items = const [], + required this.confirmLabel, + required this.confirmDelay, + }); + + @override + State<_InfoActionSheet> createState() => _InfoActionSheetState(); +} + +class _InfoActionSheetState extends State<_InfoActionSheet> { + Timer? _ticker; + int _remaining = 0; + + @override + void initState() { + super.initState(); + _remaining = widget.confirmDelay.inSeconds; + if (_remaining > 0) { + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() { + _remaining--; + if (_remaining == 0) { + _ticker?.cancel(); + _ticker = null; + } + }); + }); + } + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final enabled = _remaining == 0; + final buttonText = enabled + ? widget.confirmLabel + : '${widget.confirmLabel}($_remaining)'; + final hasHeader = widget.headerEmoji != null || widget.headerIcon != null; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasHeader) ...[ + _buildHeader(cs), + const SizedBox(height: 18), + ], + Text( + widget.title, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + if (widget.subtitle != null) ...[ + const SizedBox(height: 6), + Text( + widget.subtitle!, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ], + if (widget.items.isNotEmpty) ...[ + const SizedBox(height: 22), + for (int i = 0; i < widget.items.length; i++) ...[ + _buildItem(cs, widget.items[i]), + if (i < widget.items.length - 1) + const SizedBox(height: 18), + ], + ], + ], + ), + ), + ), + const SizedBox(height: 28), + FilledButton( + onPressed: enabled ? () => Navigator.pop(context, true) : null, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + disabledBackgroundColor: cs.primary.withValues(alpha: 0.45), + disabledForegroundColor: cs.onPrimary.withValues(alpha: 0.85), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + ), + child: Text( + buttonText, + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader(ColorScheme cs) { + if (widget.headerEmoji != null) { + return Center( + child: Text( + widget.headerEmoji!, + style: const TextStyle(fontSize: 72, height: 1.0), + ), + ); + } + return Center( + child: Icon( + widget.headerIcon, + size: 72, + color: cs.primary, + weight: 400, + ), + ); + } + + Widget _buildItem(ColorScheme cs, InfoActionSheetItem item) { + final titleColor = item.titleColor ?? cs.onSurface; + final iconColor = item.iconColor ?? cs.onSurfaceVariant; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + item.icon, + size: 26, + color: iconColor, + weight: 400, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: GoogleFonts.inter( + color: titleColor, + fontSize: 16, + fontWeight: FontWeight.w700, + height: 1.25, + ), + ), + const SizedBox(height: 4), + Text( + item.body, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } +} + diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c7b227b..963fb5c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -73,8 +73,12 @@ "spoofFieldScreen": "Screen resolution", "spoofFieldTimezone": "Timezone", "spoofFieldLocale": "Locale", + "spoofFieldDeviceLocale": "Device locale (derived)", "spoofIdentifiersSectionTitle": "Identifiers", "spoofIdentifiersDescription": "mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.", + "spoofFieldInstanceId": "mt_instanceid", + "spoofFieldClientSessionId": "clientSessionId", + "spoofFieldPushDeviceType": "Push device type", "spoofFieldDeviceId": "Device ID", "spoofRegenerateIdTooltip": "Generate a new ID", "spoofFieldAppVersion": "App version", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ce14093..ec39c9d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -482,6 +482,12 @@ abstract class AppLocalizations { /// **'Locale'** String get spoofFieldLocale; + /// No description provided for @spoofFieldDeviceLocale. + /// + /// In en, this message translates to: + /// **'Device locale (derived)'** + String get spoofFieldDeviceLocale; + /// No description provided for @spoofIdentifiersSectionTitle. /// /// In en, this message translates to: @@ -494,6 +500,24 @@ abstract class AppLocalizations { /// **'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'** String get spoofIdentifiersDescription; + /// No description provided for @spoofFieldInstanceId. + /// + /// In en, this message translates to: + /// **'mt_instanceid'** + String get spoofFieldInstanceId; + + /// No description provided for @spoofFieldClientSessionId. + /// + /// In en, this message translates to: + /// **'clientSessionId'** + String get spoofFieldClientSessionId; + + /// No description provided for @spoofFieldPushDeviceType. + /// + /// In en, this message translates to: + /// **'Push device type'** + String get spoofFieldPushDeviceType; + /// No description provided for @spoofFieldDeviceId. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6f4c3c6..62d18b3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -209,6 +209,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get spoofFieldLocale => 'Locale'; + @override + String get spoofFieldDeviceLocale => 'Device locale (derived)'; + @override String get spoofIdentifiersSectionTitle => 'Identifiers'; @@ -216,6 +219,15 @@ class AppLocalizationsEn extends AppLocalizations { String get spoofIdentifiersDescription => 'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'; + @override + String get spoofFieldInstanceId => 'mt_instanceid'; + + @override + String get spoofFieldClientSessionId => 'clientSessionId'; + + @override + String get spoofFieldPushDeviceType => 'Push device type'; + @override String get spoofFieldDeviceId => 'Device ID'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 999ea90..7b439e7 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -211,6 +211,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get spoofFieldLocale => 'Локаль'; + @override + String get spoofFieldDeviceLocale => 'Системная локаль (производная)'; + @override String get spoofIdentifiersSectionTitle => 'Идентификаторы'; @@ -218,6 +221,15 @@ class AppLocalizationsRu extends AppLocalizations { String get spoofIdentifiersDescription => 'mt_instanceid и clientSessionId генерируются автоматически при каждом запуске приложения. Изменить можно только Device ID.'; + @override + String get spoofFieldInstanceId => 'mt_instanceid'; + + @override + String get spoofFieldClientSessionId => 'clientSessionId'; + + @override + String get spoofFieldPushDeviceType => 'Тип push-уведомлений'; + @override String get spoofFieldDeviceId => 'ID Устройства'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3d134a9..7b3739e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -73,8 +73,12 @@ "spoofFieldScreen": "Разрешение экрана", "spoofFieldTimezone": "Часовой пояс", "spoofFieldLocale": "Локаль", + "spoofFieldDeviceLocale": "Системная локаль (производная)", "spoofIdentifiersSectionTitle": "Идентификаторы", "spoofIdentifiersDescription": "mt_instanceid и clientSessionId генерируются автоматически при каждом запуске приложения. Изменить можно только Device ID.", + "spoofFieldInstanceId": "mt_instanceid", + "spoofFieldClientSessionId": "clientSessionId", + "spoofFieldPushDeviceType": "Тип push-уведомлений", "spoofFieldDeviceId": "ID Устройства", "spoofRegenerateIdTooltip": "Сгенерировать новый ID", "spoofFieldAppVersion": "Версия приложения", From dcb39fe06c63e6a54c4f12c07bd1161f06ecb3ba Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 25 May 2026 11:15:43 +0000 Subject: [PATCH 152/323] =?UTF-8?q?=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B2=D0=BE=D1=80=D0=BA=D1=84=D0=BB=D0=BE?= =?UTF-8?q?=D1=83=20=D0=BF=D0=BE=D0=B4=20iOS,=20macOS,=20Linux=20=D0=B8=20?= =?UTF-8?q?Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-ios.yml | 90 +++++++++++++++++++++++++++++ .github/workflows/build-linux.yml | 60 +++++++++++++++++++ .github/workflows/build-macos.yml | 56 ++++++++++++++++++ .github/workflows/build-windows.yml | 56 ++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 .github/workflows/build-ios.yml create mode 100644 .github/workflows/build-linux.yml create mode 100644 .github/workflows/build-macos.yml create mode 100644 .github/workflows/build-windows.yml diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml new file mode 100644 index 0000000..29c898b --- /dev/null +++ b/.github/workflows/build-ios.yml @@ -0,0 +1,90 @@ +name: Build iOS + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'lib/**' + - 'ios/**' + - 'pubspec.yaml' + - '.github/workflows/build-ios.yml' + pull_request: + paths: + - 'lib/**' + - 'ios/**' + - 'pubspec.yaml' + - '.github/workflows/build-ios.yml' + +env: + FLUTTER_VERSION: '3.41.5' + +jobs: + build-ios: + runs-on: macos-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v3 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + + - name: Get dependencies + run: flutter pub get + + - name: Build iOS (no codesign) + run: flutter build ios --release --no-codesign + + - name: Install ldid + run: brew install ldid + + - name: Pseudo-sign with ldid + run: | + set -euo pipefail + APP="build/ios/iphoneos/Runner.app" + ENT="$(mktemp -t ent).plist" + cat > "$ENT" <<'PLIST' + + + + + platform-application + get-task-allow + com.apple.private.security.no-container + + + PLIST + if [ -d "$APP/Frameworks" ]; then + find "$APP/Frameworks" -type f -name "*.dylib" -print0 | xargs -0 -I{} ldid -S "{}" + find "$APP/Frameworks" -type d -name "*.framework" | while read -r fw; do + bin="$fw/$(basename "$fw" .framework)" + [ -f "$bin" ] && ldid -S "$bin" + done + fi + if [ -d "$APP/PlugIns" ]; then + find "$APP/PlugIns" -type d -name "*.appex" | while read -r ext; do + bin="$ext/$(basename "$ext" .appex)" + [ -f "$bin" ] && ldid -S"$ENT" "$bin" + done + fi + ldid -S"$ENT" "$APP/Runner" + + - name: Package IPA + run: | + mkdir -p dist build/ios/Payload + cp -R build/ios/iphoneos/Runner.app build/ios/Payload/ + (cd build/ios && zip -qr ../../dist/Komet-ios-pseudosigned.ipa Payload) + + - name: Upload iOS artifact + uses: actions/upload-artifact@v4 + with: + name: komet-ios-pseudosigned + path: dist/Komet-ios-pseudosigned.ipa + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml new file mode 100644 index 0000000..d7d488c --- /dev/null +++ b/.github/workflows/build-linux.yml @@ -0,0 +1,60 @@ +name: Build Linux + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'lib/**' + - 'linux/**' + - 'pubspec.yaml' + - '.github/workflows/build-linux.yml' + pull_request: + paths: + - 'lib/**' + - 'linux/**' + - 'pubspec.yaml' + - '.github/workflows/build-linux.yml' + +env: + FLUTTER_VERSION: '3.41.5' + +jobs: + build-linux: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v3 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + + - name: Install Linux desktop dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev + + - name: Get dependencies + run: flutter pub get + + - name: Build Linux + run: flutter build linux --release + + - name: Package Linux + run: | + mkdir -p dist + tar -C build/linux/x64/release/bundle -czf dist/Komet-linux-x64.tar.gz . + + - name: Upload Linux artifact + uses: actions/upload-artifact@v4 + with: + name: komet-linux-x64 + path: dist/Komet-linux-x64.tar.gz + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 0000000..cfb2857 --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -0,0 +1,56 @@ +name: Build macOS + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'lib/**' + - 'macos/**' + - 'pubspec.yaml' + - '.github/workflows/build-macos.yml' + pull_request: + paths: + - 'lib/**' + - 'macos/**' + - 'pubspec.yaml' + - '.github/workflows/build-macos.yml' + +env: + FLUTTER_VERSION: '3.41.5' + +jobs: + build-macos: + runs-on: macos-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v3 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + + - name: Get dependencies + run: flutter pub get + + - name: Build macOS + run: flutter build macos --release + + - name: Package macOS + run: | + mkdir -p dist + APP=$(find build/macos/Build/Products/Release -maxdepth 1 -name "*.app" | head -1) + ditto -c -k --keepParent "$APP" dist/Komet-macos.zip + + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: komet-macos + path: dist/Komet-macos.zip + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..68c4390 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,56 @@ +name: Build Windows + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'lib/**' + - 'windows/**' + - 'pubspec.yaml' + - '.github/workflows/build-windows.yml' + pull_request: + paths: + - 'lib/**' + - 'windows/**' + - 'pubspec.yaml' + - '.github/workflows/build-windows.yml' + +env: + FLUTTER_VERSION: '3.41.5' + +jobs: + build-windows: + runs-on: windows-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v3 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + + - name: Get dependencies + run: flutter pub get + + - name: Build Windows + run: flutter build windows --release + + - name: Package Windows + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath dist/Komet-windows-x64.zip + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: komet-windows-x64 + path: dist/Komet-windows-x64.zip + if-no-files-found: error + retention-days: 30 From 7a9aa71c5e797c08dfe4e72f1a59e13f03fef886 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Mon, 25 May 2026 21:09:29 +0700 Subject: [PATCH 153/323] =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/komet-design-system.svg | 320 ++++++++++++++++ lib/frontend/screens/chats/chat_screen.dart | 8 +- .../screens/profile/cloud_storage_screen.dart | 361 ++++++++++++++++++ .../screens/profile/settings_tab.dart | 9 +- pubspec.lock | 8 +- 5 files changed, 699 insertions(+), 7 deletions(-) create mode 100644 design/komet-design-system.svg create mode 100644 lib/frontend/screens/profile/cloud_storage_screen.dart diff --git a/design/komet-design-system.svg b/design/komet-design-system.svg new file mode 100644 index 0000000..dcca5f3 --- /dev/null +++ b/design/komet-design-system.svg @@ -0,0 +1,320 @@ + + + + + + + + + + Komet — Design System + Seed #C1C4FF · Material 3 Expressive · Inter + + + + Цвета — Light + + + + + primary + #4D55A9 + + + + primaryContainer + #DFE0FF + + + + surface + #FEFBFF + + + + surfaceContainer + #F2EEF8 + + + + surfaceContainerHigh + #ECE8F2 + + + + surfaceContainerHighest + #E6E2EC + + + + + onSurface + #1B1B21 + + + + onSurfaceVariant + #46464F + + + + outline + #767680 + + + + outlineVariant + #C6C5D0 + + + + error + #BA1A1A + + + + errorContainer + #FFDAD6 + + + + + + Цвета — Dark + + + + + + primary + #BCC2FF + + + + primaryContainer + #353D90 + + + + surface + #131318 + + + + surfaceContainer + #1F1F25 + + + + surfaceContainerHigh + #2A2A30 + + + + surfaceContainerHighest + #35353B + + + + + onSurface + #E4E1E9 + + + + onSurfaceVariant + #C6C5D0 + + + + outline + #90909A + + + + outlineVariant + #46464F + + + + error + #FFB4AB + + + + errorContainer + #93000A + + + + + + Типографика — Inter + + Display / 32 / 700 + Headline / 22 / 600 + Title / 18 / 600 + Subtitle / 16 / 500 + Body / 14 / 400 — основной текст в интерфейсе. + Caption / 13 / 400 — подсказки, метаданные. + LABEL / 12 / 500 / UPPER + + + + + Компонент: Settings Item + + + + + + Цифровой ID + + + + + + + + Облачное хранилище + + BETA + + + + + + + + Уведомления + + + + + + + + + Выйти из аккаунта + + + + + Кнопки + + + + Filled · Primary + + + + + Tonal + + + + + Outlined + + + + Text button + + + + + Destructive + + + + + + + + + + Компонент: InfoActionSheet + + + + + + + Облачное хранилище + Через МАХ + + + + + + Работает при белых списках + Можете передать файл при ограничениях. + + + + + Файлы до 4 ГБ, безлимитно + Хранилище большого объёма. + + + + + Без конфиденциальности + Файлы хранятся на сервере МАХ. + + + + + ОК + + + + + Шаблон экрана + + + + + + Заголовок + + + + + + Заголовок состояния + Подпись · описание + + + + + Phone + + + + + + + Облачное хранилище + + + + + + Скоро + Экран в разработке + + + + + Импорт в Figma: File → Place / drag & drop. Слои сохранят имена (id), текст редактируемый. + + diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 96884ff..e822ed5 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -804,14 +804,18 @@ class _ChatScreenState extends State animation: _attachAnim, builder: (context, _) { if (_attachAnim.value == 0) return const SizedBox.shrink(); + final curve = _attachAnim.status == AnimationStatus.reverse + ? Curves.easeIn + : Curves.easeOut; + final t = curve.transform(_attachAnim.value); return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: ClipRect( child: Align( alignment: Alignment.bottomCenter, - heightFactor: Curves.easeOutCubic.transform(_attachAnim.value), + heightFactor: t, child: Opacity( - opacity: Curves.easeOut.transform(_attachAnim.value), + opacity: t, child: AttachmentPanel( onClose: () => _showAttachmentPanel.value = false, onPickFile: _pickAndUploadFile, diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart new file mode 100644 index 0000000..d49ccd9 --- /dev/null +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -0,0 +1,361 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class CloudStorageScreen extends StatefulWidget { + const CloudStorageScreen({super.key}); + + @override + State createState() => _CloudStorageScreenState(); +} + +class _CloudStorageScreenState extends State + with SingleTickerProviderStateMixin { + static const _translateFactor = 0.7; + static const _horizontalPadding = 32.0; + static const _hintSidePadding = 35.0; + static const _cornerSidePadding = 16.0; + static const _cornerBottomPadding = 24.0; + static const _cornerSlideAmount = 28.0; + + late final _UploadModeController _mode; + + @override + void initState() { + super.initState(); + _mode = _UploadModeController(this); + } + + @override + void dispose() { + _mode.dispose(); + super.dispose(); + } + + void _onBack() { + if (_mode.isOpen) { + _mode.close(); + } else { + Navigator.pop(context); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _onBack, + ), + title: Text( + 'Облачное хранилище', + style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), + ), + ), + body: LayoutBuilder( + builder: (context, constraints) { + final h = constraints.maxHeight; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (d) => _mode.handleDragUpdate(d, h), + onVerticalDragEnd: _mode.handleDragEnd, + child: AnimatedBuilder( + animation: _mode.anim, + builder: (context, _) { + final t = Curves.easeOutCubic.transform(_mode.anim.value); + return Stack( + fit: StackFit.expand, + children: [ + _buildHint(cs, t), + _buildUploadingCenterHint(cs, t), + _buildEmptyState(cs, t, h), + ..._buildCornerActions(cs, t), + ], + ); + }, + ), + ); + }, + ), + ); + } + + Widget _buildHint(ColorScheme cs, double t) { + return Positioned( + top: 0, + left: _hintSidePadding, + right: _hintSidePadding, + child: IgnorePointer( + child: Opacity( + opacity: t, + child: _DragDownHint(color: cs.onSurfaceVariant), + ), + ), + ); + } + + Widget _buildUploadingCenterHint(ColorScheme cs, double t) { + return Center( + child: Opacity( + opacity: t, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), + child: Text( + 'Начните загрузку для прогресс-бара', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ), + ), + ); + } + + Widget _buildEmptyState(ColorScheme cs, double t, double availableHeight) { + return Transform.translate( + offset: Offset(0, -t * availableHeight * _translateFactor), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Облачных файлов пока нет...', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + 'Добавите?', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _mode.open, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: const Text( + 'Загрузить', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ); + } + + List _buildCornerActions(ColorScheme cs, double t) { + final slide = (1 - t) * _cornerSlideAmount; + return [ + Positioned( + bottom: _cornerBottomPadding, + left: _cornerSidePadding - slide, + child: Opacity( + opacity: t, + child: _CornerAction( + icon: Symbols.upload_file, + label: 'С файла', + onTap: () {}, + ), + ), + ), + Positioned( + bottom: _cornerBottomPadding, + right: _cornerSidePadding - slide, + child: Opacity( + opacity: t, + child: _CornerAction( + icon: Symbols.tag, + label: 'По ID', + onTap: () {}, + ), + ), + ), + ]; + } +} + +class _UploadModeController { + static const _openDuration = Duration(milliseconds: 480); + static const _closeDuration = Duration(milliseconds: 320); + static const _dragRangeFactor = 0.55; + static const _flingVelocityThreshold = 280.0; + static const _snapMidpoint = 0.5; + + final AnimationController anim; + + _UploadModeController(TickerProvider vsync) + : anim = AnimationController( + vsync: vsync, + duration: _openDuration, + reverseDuration: _closeDuration, + ); + + bool get isOpen => anim.value > 0; + + void open() => anim.forward(); + void close() => anim.reverse(); + + void handleDragUpdate(DragUpdateDetails d, double availableHeight) { + if (anim.value == 0) return; + final delta = d.primaryDelta ?? 0; + final next = anim.value - delta / (availableHeight * _dragRangeFactor); + anim.value = next.clamp(0.0, 1.0); + } + + void handleDragEnd(DragEndDetails d) { + final v = d.primaryVelocity ?? 0; + if (v.abs() > _flingVelocityThreshold) { + v > 0 ? close() : open(); + return; + } + anim.value < _snapMidpoint ? close() : open(); + } + + void dispose() => anim.dispose(); +} + +class _CornerAction extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _CornerAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: cs.onSurface, size: 20), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _DragDownHint extends StatefulWidget { + final Color color; + + const _DragDownHint({required this.color}); + + @override + State<_DragDownHint> createState() => _DragDownHintState(); +} + +class _DragDownHintState extends State<_DragDownHint> + with SingleTickerProviderStateMixin { + static const _cycle = Duration(milliseconds: 2800); + static const _activeFraction = 0.4; + static const _height = 3.0; + static const _slotHeight = 28.0; + static const _startY = -6.0; + static const _travel = 16.0; + static const _peakOpacity = 0.32; + + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController(vsync: this, duration: _cycle)..repeat(); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _slotHeight, + child: AnimatedBuilder( + animation: _c, + builder: (context, _) { + final ghost = _ghostFor(_c.value); + if (ghost.opacity <= 0) return const SizedBox.shrink(); + return Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: ghost.dy, + height: _height, + child: Opacity( + opacity: ghost.opacity, + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.color, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + + ({double dy, double opacity}) _ghostFor(double phase) { + if (phase > _activeFraction) return (dy: 0, opacity: 0); + final local = phase / _activeFraction; + final eased = Curves.easeOutCubic.transform(local); + return ( + dy: _startY + eased * _travel, + opacity: (1 - local) * _peakOpacity, + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 16300fe..5f9c7d5 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -12,6 +12,7 @@ import '../../../main.dart'; import '../../widgets/info_action_sheet.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; +import 'cloud_storage_screen.dart'; import 'customization_screen.dart'; import 'performance_screen.dart'; import 'debug_menu_screen.dart'; @@ -101,7 +102,7 @@ class _SettingsTabState extends State { Future _openCloudStorage(BuildContext context) async { final cs = Theme.of(context).colorScheme; - await showInfoActionSheet( + final ok = await showInfoActionSheet( context, headerIcon: Symbols.cloud, title: 'Облачное хранилище', @@ -128,6 +129,12 @@ class _SettingsTabState extends State { ], confirmLabel: 'ОК', confirmDelay: const Duration(seconds: 3), + seenKey: 'cloud_storage_intro_seen', + ); + if (!ok || !context.mounted) return; + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const CloudStorageScreen()), ); } diff --git a/pubspec.lock b/pubspec.lock index e5f589c..d2ce143 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -505,10 +505,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -902,10 +902,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" timezone: dependency: "direct main" description: From 41f7d60b81a4c5d392f18cc2059ee9db10874593 Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 25 May 2026 18:15:26 +0300 Subject: [PATCH 154/323] =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BD=D0=B5=D0=B5;=20=D0=BF=D0=BA=D0=BC=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BA=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA?= =?UTF-8?q?;=20=D1=80=D0=B0=D0=B4=D0=B8=D0=B0=D0=BB=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D1=8A=D0=B5=D0=B7=D0=B6=D0=B0=D0=B5=D1=82=20=D1=83=20?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D1=91=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 32 +++ .../widgets/message_actions_overlay.dart | 233 +++++++++++------- 2 files changed, 181 insertions(+), 84 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 96884ff..711deea 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1756,6 +1756,37 @@ class _LongPressBubbleState extends State<_LongPressBubble> { ); } + void _onSecondaryTapDown(TapDownDetails details) { + if (_controller != null) return; + final ctx = _boundaryKey.currentContext; + if (ctx == null) return; + final renderObject = ctx.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) return; + + final origin = renderObject.localToGlobal(Offset.zero); + final rect = origin & renderObject.size; + + final controller = MessageActionsController(); + _controller = controller; + + showMessageActions( + context: ctx, + originRect: rect, + tapPoint: details.globalPosition, + isMe: widget.isMe, + messageText: widget.message.text, + controller: controller, + style: MessageActionsStyle.list, + interaction: MessageActionsInteraction.click, + onDispose: () { + if (identical(_controller, controller)) { + _controller = null; + } + controller.dispose(); + }, + ); + } + @override Widget build(BuildContext context) { return Listener( @@ -1769,6 +1800,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { onLongPressMoveUpdate: (d) => _controller?.updatePointer(d.globalPosition), onLongPressEnd: (_) => _controller?.commit(), + onSecondaryTapDown: _onSecondaryTapDown, child: RepaintBoundary( key: _boundaryKey, child: widget.child, diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 67d2a07..873e0d8 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -10,6 +10,8 @@ import '../../core/config/app_message_actions_style.dart'; import '../../core/utils/haptics.dart'; import 'custom_notification.dart'; +enum MessageActionsInteraction { dragAndRelease, click } + class MessageActionsController extends ChangeNotifier { Offset? pointer; Offset? initialPointer; @@ -63,7 +65,7 @@ class MessageActionsController extends ChangeNotifier { void showMessageActions({ required BuildContext context, - required ui.Image snapshot, + ui.Image? snapshot, required Rect originRect, required Offset tapPoint, required bool isMe, @@ -71,6 +73,7 @@ void showMessageActions({ required MessageActionsController controller, required MessageActionsStyle style, required VoidCallback onDispose, + MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, }) { final overlay = Overlay.of(context, rootOverlay: true); late OverlayEntry entry; @@ -83,6 +86,7 @@ void showMessageActions({ messageText: messageText, controller: controller, style: style, + interaction: interaction, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -93,13 +97,14 @@ void showMessageActions({ } class _MessageActionsLayer extends StatefulWidget { - final ui.Image snapshot; + final ui.Image? snapshot; final Rect originRect; final Offset tapPoint; final bool isMe; final String? messageText; final MessageActionsController controller; final MessageActionsStyle style; + final MessageActionsInteraction interaction; final VoidCallback onDismiss; const _MessageActionsLayer({ @@ -110,6 +115,7 @@ class _MessageActionsLayer extends StatefulWidget { required this.messageText, required this.controller, required this.style, + required this.interaction, required this.onDismiss, }); @@ -129,6 +135,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> static const double _hMargin = 8.0; late List<_Action> _actions; + late MessageActionsStyle _effectiveStyle; bool _showBelow = true; Offset _anchor = Offset.zero; List _buttonCenters = const []; @@ -161,46 +168,95 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (_initialized) return; _initialized = true; _actions = _buildActions(); + _effectiveStyle = widget.interaction == MessageActionsInteraction.click + ? MessageActionsStyle.list + : widget.style; final screenSize = MediaQuery.sizeOf(context); - if (widget.style == MessageActionsStyle.radial) { + if (_effectiveStyle == MessageActionsStyle.radial) { _computeRadialGeometry(screenSize); } else { _computeListGeometry(screenSize); } - widget.controller.addListener(_onControllerUpdate); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _onControllerUpdate(); - }); + if (widget.interaction == MessageActionsInteraction.dragAndRelease) { + widget.controller.addListener(_onControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _onControllerUpdate(); + }); + } } void _computeRadialGeometry(Size screenSize) { _showBelow = widget.tapPoint.dy < screenSize.height * 0.55; - _anchor = _showBelow - ? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16) - : Offset(widget.tapPoint.dx, widget.originRect.top - 16); - _buttonCenters = _computeButtonCenters(screenSize); + final anchorY = _showBelow + ? widget.originRect.bottom + 16 + : widget.originRect.top - 16; + + final n = _actions.length; + final base = _showBelow ? math.pi * 0.5 : -math.pi * 0.5; + final start = base - _arcSpan / 2; + final step = n <= 1 ? 0.0 : _arcSpan / (n - 1); + + final offsets = []; + double minDx = 0; + double maxDx = 0; + for (int i = 0; i < n; i++) { + final angle = start + step * i; + final dx = math.cos(angle) * _radius; + final dy = math.sin(angle) * _radius; + offsets.add(Offset(dx, dy)); + if (dx < minDx) minDx = dx; + if (dx > maxDx) maxDx = dx; + } + + final minX = _hMargin + _btnSize / 2; + final maxX = screenSize.width - _hMargin - _btnSize / 2; + + double anchorX = widget.tapPoint.dx; + if (anchorX + maxDx > maxX) anchorX = maxX - maxDx; + if (anchorX + minDx < minX) anchorX = minX - minDx; + + _anchor = Offset(anchorX, anchorY); + _buttonCenters = [for (final o in offsets) _anchor + o]; _buttonHitRects = [ for (final c in _buttonCenters) - Rect.fromCenter(center: c, width: _hitRadius * 2, height: _hitRadius * 2), + Rect.fromCenter( + center: c, + width: _hitRadius * 2, + height: _hitRadius * 2, + ), ]; } void _computeListGeometry(Size screenSize) { final n = _actions.length; - const menuWidth = 244.0; - const itemHeight = 48.0; - const vPad = 8.0; + const menuWidth = 220.0; + const itemHeight = 42.0; + const vPad = 6.0; final menuHeight = n * itemHeight + vPad * 2; - final spaceBelow = screenSize.height - widget.originRect.bottom - 24; - final spaceAbove = widget.originRect.top - 24; - _showBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove; - final menuY = _showBelow - ? widget.originRect.bottom + 10 - : widget.originRect.top - 10 - menuHeight; - final rawX = widget.isMe - ? widget.originRect.right - menuWidth - : widget.originRect.left; - final menuX = rawX.clamp(8.0, screenSize.width - menuWidth - 8.0).toDouble(); + late double menuX; + late double menuY; + if (widget.interaction == MessageActionsInteraction.click) { + final spaceBelow = screenSize.height - widget.tapPoint.dy - 8; + _showBelow = spaceBelow >= menuHeight || widget.tapPoint.dy < menuHeight; + final rawY = _showBelow + ? widget.tapPoint.dy + : widget.tapPoint.dy - menuHeight; + menuY = rawY.clamp(8.0, screenSize.height - menuHeight - 8.0).toDouble(); + menuX = widget.tapPoint.dx + .clamp(8.0, screenSize.width - menuWidth - 8.0) + .toDouble(); + } else { + final spaceBelow = screenSize.height - widget.originRect.bottom - 24; + final spaceAbove = widget.originRect.top - 24; + _showBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove; + menuY = _showBelow + ? widget.originRect.bottom + 10 + : widget.originRect.top - 10 - menuHeight; + final rawX = widget.isMe + ? widget.originRect.right - menuWidth + : widget.originRect.left; + menuX = rawX.clamp(8.0, screenSize.width - menuWidth - 8.0).toDouble(); + } _menuRect = Rect.fromLTWH(menuX, menuY, menuWidth, menuHeight); _buttonHitRects = [ for (int i = 0; i < n; i++) @@ -217,7 +273,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> void dispose() { _animController.dispose(); widget.controller.removeListener(_onControllerUpdate); - widget.snapshot.dispose(); + widget.snapshot?.dispose(); super.dispose(); } @@ -236,26 +292,6 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ]; } - List _computeButtonCenters(Size screenSize) { - final n = _actions.length; - if (n == 0) return const []; - final base = _showBelow ? math.pi * 0.5 : -math.pi * 0.5; - final start = base - _arcSpan / 2; - final step = n == 1 ? 0.0 : _arcSpan / (n - 1); - final minX = _hMargin + _btnSize / 2; - final maxX = screenSize.width - _hMargin - _btnSize / 2; - return [ - for (int i = 0; i < n; i++) - () { - final angle = start + step * i; - var p = _anchor + Offset(math.cos(angle), math.sin(angle)) * _radius; - if (p.dx < minX) p = Offset(minX, p.dy); - if (p.dx > maxX) p = Offset(maxX, p.dy); - return p; - }(), - ]; - } - void _onControllerUpdate() { if (!mounted) return; @@ -319,6 +355,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); + final isClick = widget.interaction == MessageActionsInteraction.click; return AnimatedBuilder( animation: _animation, builder: (ctx, _) { @@ -331,33 +368,36 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> behavior: HitTestBehavior.opaque, child: Stack( children: [ - Positioned.fill( - child: BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: blurSigma, - sigmaY: blurSigma, - ), - child: ColoredBox( - color: Colors.black.withValues(alpha: 0.22 * t), + if (!isClick) ...[ + Positioned.fill( + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.22 * t), + ), ), ), - ), - Positioned( - left: widget.originRect.left, - top: widget.originRect.top, - width: widget.originRect.width, - height: widget.originRect.height, - child: Transform.scale( - scale: bubbleScale, - child: RawImage( - image: widget.snapshot, + if (widget.snapshot != null) + Positioned( + left: widget.originRect.left, + top: widget.originRect.top, width: widget.originRect.width, height: widget.originRect.height, - fit: BoxFit.fill, + child: Transform.scale( + scale: bubbleScale, + child: RawImage( + image: widget.snapshot, + width: widget.originRect.width, + height: widget.originRect.height, + fit: BoxFit.fill, + ), + ), ), - ), - ), - if (widget.style == MessageActionsStyle.radial) ...[ + ], + if (_effectiveStyle == MessageActionsStyle.radial) ...[ ..._buildButtons(t), _buildLabelBanner(size, t), ] else @@ -373,6 +413,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> final cs = Theme.of(context).colorScheme; final eased = Curves.easeOutCubic.transform(t); final scale = 0.88 + 0.12 * eased; + final isClick = widget.interaction == MessageActionsInteraction.click; return Positioned( left: _menuRect.left, top: _menuRect.top, @@ -382,26 +423,39 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> opacity: eased, child: Transform.scale( scale: scale, - alignment: Alignment( - widget.isMe ? 1.0 : -1.0, - _showBelow ? -1.0 : 1.0, - ), + alignment: isClick + ? Alignment(-1.0, _showBelow ? -1.0 : 1.0) + : Alignment( + widget.isMe ? 1.0 : -1.0, + _showBelow ? -1.0 : 1.0, + ), child: Material( color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(18), + borderRadius: BorderRadius.circular(16), clipBehavior: Clip.antiAlias, elevation: 8, shadowColor: Colors.black.withValues(alpha: 0.4), child: Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 8), + const SizedBox(height: 6), for (int i = 0; i < _actions.length; i++) _ListMenuItem( action: _actions[i], highlighted: _hoveredIndex == i, + onHoverChanged: isClick + ? (hovered) { + if (hovered) { + if (_hoveredIndex != i) { + setState(() => _hoveredIndex = i); + } + } else if (_hoveredIndex == i) { + setState(() => _hoveredIndex = -1); + } + } + : null, ), - const SizedBox(height: 8), + const SizedBox(height: 6), ], ), ), @@ -515,7 +569,12 @@ class _Action { class _ListMenuItem extends StatelessWidget { final _Action action; final bool highlighted; - const _ListMenuItem({required this.action, required this.highlighted}); + final ValueChanged? onHoverChanged; + const _ListMenuItem({ + required this.action, + required this.highlighted, + this.onHoverChanged, + }); @override Widget build(BuildContext context) { @@ -524,8 +583,8 @@ class _ListMenuItem extends StatelessWidget { final onPill = action.destructive ? cs.onError : cs.onPrimary; final restFg = action.destructive ? cs.error : cs.onSurface; final fg = highlighted ? onPill : restFg; - return SizedBox( - height: 48, + final inner = SizedBox( + height: 42, child: Material( color: Colors.transparent, child: InkWell( @@ -534,25 +593,25 @@ class _ListMenuItem extends StatelessWidget { action.onTap(); }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), child: AnimatedContainer( duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic, decoration: BoxDecoration( color: highlighted ? pillBg : Colors.transparent, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(18), ), - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - Icon(action.icon, color: fg, size: 22), - const SizedBox(width: 14), + Icon(action.icon, color: fg, size: 20), + const SizedBox(width: 12), Expanded( child: Text( action.label, style: TextStyle( color: fg, - fontSize: 15, + fontSize: 14, fontWeight: highlighted ? FontWeight.w600 : FontWeight.w500, ), @@ -565,6 +624,12 @@ class _ListMenuItem extends StatelessWidget { ), ), ); + if (onHoverChanged == null) return inner; + return MouseRegion( + onEnter: (_) => onHoverChanged!(true), + onExit: (_) => onHoverChanged!(false), + child: inner, + ); } } From 0bc3b6fa86e45239acd0994c5d6ec64c1ca05db2 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 26 May 2026 05:41:54 +0000 Subject: [PATCH 155/323] =?UTF-8?q?feat:=20=D0=BA=D1=80=D0=B0=D1=81=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=83=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D1=88=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B2=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/code_confirmation_screen.dart | 11 +- .../screens/auth/password_2fa_screen.dart | 11 +- .../widgets/login_success_screen.dart | 402 ++++++++++++++++++ 3 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 lib/frontend/widgets/login_success_screen.dart diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 512a938..1f776aa 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -3,10 +3,10 @@ import 'package:flutter/material.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; -import '../chats/chat_list_screen.dart'; import 'password_2fa_screen.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/login_success_screen.dart'; class CodeConfirmationScreen extends StatefulWidget { final String phoneNumber; @@ -173,7 +173,14 @@ class _CodeConfirmationScreenState extends State Navigator.pushAndRemoveUntil( context, - MaterialPageRoute(builder: (context) => const ChatListScreen()), + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => const LoginSuccessScreen(), + transitionsBuilder: (_, animation, __, child) => FadeTransition( + opacity: animation, + child: child, + ), + ), (route) => false, ); } catch (e) { diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index b38171e..fae5643 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -import '../chats/chat_list_screen.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/login_success_screen.dart'; class Password2FAScreen extends StatefulWidget { final String trackId; @@ -46,7 +46,14 @@ class _Password2FAScreenState extends State { Navigator.pushAndRemoveUntil( context, - MaterialPageRoute(builder: (context) => const ChatListScreen()), + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => const LoginSuccessScreen(), + transitionsBuilder: (_, animation, __, child) => FadeTransition( + opacity: animation, + child: child, + ), + ), (route) => false, ); } catch (e) { diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart new file mode 100644 index 0000000..74aa9e9 --- /dev/null +++ b/lib/frontend/widgets/login_success_screen.dart @@ -0,0 +1,402 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../core/utils/haptics.dart'; +import '../screens/chats/chat_list_screen.dart'; + +class LoginSuccessScreen extends StatefulWidget { + const LoginSuccessScreen({super.key}); + + @override + State createState() => _LoginSuccessScreenState(); +} + +class _LoginSuccessScreenState extends State + with SingleTickerProviderStateMixin { + static const Duration _duration = Duration(milliseconds: 1900); + + late final AnimationController _controller; + late final Animation _circleScale; + late final Animation _ringSweep; + late final Animation _checkProgress; + late final Animation _particles; + late final Animation _haloOpacity; + late final Animation _haloScale; + late final Animation _titleOpacity; + late final Animation _titleOffset; + late final Animation _subtitleOpacity; + late final Animation _subtitleOffset; + late final Animation _fadeOut; + + bool _navigated = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: _duration); + + _circleScale = CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.22, curve: Curves.easeOutBack), + ); + _ringSweep = CurvedAnimation( + parent: _controller, + curve: const Interval(0.08, 0.45, curve: Curves.easeOutCubic), + ); + _checkProgress = CurvedAnimation( + parent: _controller, + curve: const Interval(0.22, 0.48, curve: Curves.easeOutCubic), + ); + _haloOpacity = CurvedAnimation( + parent: _controller, + curve: const Interval(0.12, 0.65, curve: Curves.easeOut), + ); + _haloScale = CurvedAnimation( + parent: _controller, + curve: const Interval(0.12, 0.85, curve: Curves.easeOutCubic), + ); + _particles = CurvedAnimation( + parent: _controller, + curve: const Interval(0.32, 0.78, curve: Curves.easeOutCubic), + ); + _titleOpacity = CurvedAnimation( + parent: _controller, + curve: const Interval(0.42, 0.62, curve: Curves.easeOut), + ); + _titleOffset = CurvedAnimation( + parent: _controller, + curve: const Interval(0.42, 0.7, curve: Curves.easeOutCubic), + ); + _subtitleOpacity = CurvedAnimation( + parent: _controller, + curve: const Interval(0.5, 0.72, curve: Curves.easeOut), + ); + _subtitleOffset = CurvedAnimation( + parent: _controller, + curve: const Interval(0.5, 0.78, curve: Curves.easeOutCubic), + ); + _fadeOut = CurvedAnimation( + parent: _controller, + curve: const Interval(0.88, 1.0, curve: Curves.easeInCubic), + ); + + _controller.addStatusListener(_onStatus); + _controller.forward(); + Haptics.success(); + } + + void _onStatus(AnimationStatus status) { + if (status == AnimationStatus.completed && !_navigated && mounted) { + _navigated = true; + Navigator.of(context).pushAndRemoveUntil( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 360), + reverseTransitionDuration: const Duration(milliseconds: 200), + pageBuilder: (_, __, ___) => const ChatListScreen(), + transitionsBuilder: (_, animation, __, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + child: child, + ); + }, + ), + (route) => false, + ); + } + } + + @override + void dispose() { + _controller.removeStatusListener(_onStatus); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Opacity( + opacity: 1.0 - _fadeOut.value, + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment.center, + radius: 0.9, + colors: [ + cs.primaryContainer.withValues( + alpha: 0.35 * _haloOpacity.value, + ), + cs.surface.withValues(alpha: 0), + ], + ), + ), + ), + ), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 220, + height: 220, + child: Stack( + alignment: Alignment.center, + children: [ + _buildHalo(cs), + _buildParticles(cs), + _buildRing(cs), + _buildCircle(cs), + ], + ), + ), + const SizedBox(height: 28), + _buildTitle(cs), + const SizedBox(height: 8), + _buildSubtitle(cs), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildHalo(ColorScheme cs) { + final scale = 0.8 + _haloScale.value * 0.6; + final opacity = (1.0 - _haloScale.value) * 0.6 * _haloOpacity.value; + return IgnorePointer( + child: Transform.scale( + scale: scale, + child: Container( + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primary.withValues(alpha: opacity * 0.25), + boxShadow: [ + BoxShadow( + color: cs.primary.withValues(alpha: opacity * 0.4), + blurRadius: 60, + spreadRadius: 8, + ), + ], + ), + ), + ), + ); + } + + Widget _buildParticles(ColorScheme cs) { + return IgnorePointer( + child: CustomPaint( + size: const Size(220, 220), + painter: _ParticlesPainter( + progress: _particles.value, + color: cs.primary, + ), + ), + ); + } + + Widget _buildRing(ColorScheme cs) { + return IgnorePointer( + child: CustomPaint( + size: const Size(140, 140), + painter: _RingPainter( + progress: _ringSweep.value, + color: cs.primary, + ), + ), + ); + } + + Widget _buildCircle(ColorScheme cs) { + final scale = _circleScale.value.clamp(0.0, 1.0); + return Transform.scale( + scale: scale, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primary, + boxShadow: [ + BoxShadow( + color: cs.primary.withValues(alpha: 0.4), + blurRadius: 24, + spreadRadius: 2, + ), + ], + ), + child: CustomPaint( + painter: _CheckPainter( + progress: _checkProgress.value, + color: cs.onPrimary, + ), + ), + ), + ); + } + + Widget _buildTitle(ColorScheme cs) { + return Opacity( + opacity: _titleOpacity.value, + child: Transform.translate( + offset: Offset(0, 18 * (1 - _titleOffset.value)), + child: Text( + 'Готово!', + style: TextStyle( + color: cs.onSurface, + fontSize: 26, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + ), + ); + } + + Widget _buildSubtitle(ColorScheme cs) { + return Opacity( + opacity: _subtitleOpacity.value, + child: Transform.translate( + offset: Offset(0, 14 * (1 - _subtitleOffset.value)), + child: Text( + 'Добро пожаловать в Komet', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} + +class _CheckPainter extends CustomPainter { + final double progress; + final Color color; + + _CheckPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + if (progress <= 0) return; + + final w = size.width; + final h = size.height; + final p1 = Offset(w * 0.30, h * 0.52); + final p2 = Offset(w * 0.45, h * 0.67); + final p3 = Offset(w * 0.72, h * 0.38); + + final firstLen = (p2 - p1).distance; + final secondLen = (p3 - p2).distance; + final total = firstLen + secondLen; + final drawn = total * progress; + + final path = Path()..moveTo(p1.dx, p1.dy); + if (drawn <= firstLen) { + final t = drawn / firstLen; + final mid = Offset.lerp(p1, p2, t)!; + path.lineTo(mid.dx, mid.dy); + } else { + path.lineTo(p2.dx, p2.dy); + final t = ((drawn - firstLen) / secondLen).clamp(0.0, 1.0); + final end = Offset.lerp(p2, p3, t)!; + path.lineTo(end.dx, end.dy); + } + + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 8 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _CheckPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.color != color; +} + +class _RingPainter extends CustomPainter { + final double progress; + final Color color; + + _RingPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + if (progress <= 0) return; + + final rect = Offset.zero & size; + final paint = Paint() + ..color = color.withValues(alpha: 0.55) + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round; + + canvas.drawArc( + rect.deflate(4), + -math.pi / 2, + 2 * math.pi * progress, + false, + paint, + ); + } + + @override + bool shouldRepaint(covariant _RingPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.color != color; +} + +class _ParticlesPainter extends CustomPainter { + final double progress; + final Color color; + + static const int _count = 10; + static const double _startRadius = 60; + static const double _endRadius = 104; + + _ParticlesPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + if (progress <= 0 || progress >= 1) return; + + final center = Offset(size.width / 2, size.height / 2); + final paint = Paint()..style = PaintingStyle.fill; + + for (int i = 0; i < _count; i++) { + final angle = (i / _count) * 2 * math.pi + (math.pi / 2); + final radius = _startRadius + (_endRadius - _startRadius) * progress; + final pos = center + Offset(math.cos(angle), math.sin(angle)) * radius; + final fade = 1.0 - progress; + final dotSize = 4.5 * fade + 1.0; + paint.color = color.withValues(alpha: fade * 0.9); + canvas.drawCircle(pos, dotSize, paint); + } + } + + @override + bool shouldRepaint(covariant _ParticlesPainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.color != color; +} From 177989b7dde6bea1d4ea9c4ad7e85d1dc460da1f Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 26 May 2026 17:13:26 +0300 Subject: [PATCH 156/323] =?UTF-8?q?feat:=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D1=80=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0,=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=B2?= =?UTF-8?q?=D1=8B=D1=85=D0=BE=D0=B4=D0=B0=20=D0=B2=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 8 ++ .../auth/code_confirmation_screen.dart | 11 ++- .../screens/auth/password_2fa_screen.dart | 11 ++- .../screens/profile/settings_tab.dart | 95 ++++++++++--------- .../widgets/login_success_screen.dart | 66 ++++++++++--- 5 files changed, 127 insertions(+), 64 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e64996e..2255a74 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -24,6 +24,12 @@ class ContactCache { static String? getAvatar(int id) => _avatarCache[id]; static Set? getOptions(int id) => _optionsCache[id]; static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false; + + static void clear() { + _nameCache.clear(); + _avatarCache.clear(); + _optionsCache.clear(); + } } class TranscriptionResult { @@ -52,6 +58,8 @@ class TranscriptionCache { static TranscriptionResult? get(String messageId) => _cache[messageId]; static bool has(String messageId) => _cache.containsKey(messageId); + + static void clear() => _cache.clear(); } class FileHistoryEntry { diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 1f776aa..5a52c01 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -167,7 +167,14 @@ class _CodeConfirmationScreenState extends State return; } - await accountModule.login(); + final loginResult = await accountModule.login(); + + if (!mounted) return; + + final avatar = await precacheLoginAvatar( + context, + loginResult.profile.baseUrl, + ); if (!mounted) return; @@ -175,7 +182,7 @@ class _CodeConfirmationScreenState extends State context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 240), - pageBuilder: (_, __, ___) => const LoginSuccessScreen(), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), transitionsBuilder: (_, animation, __, child) => FadeTransition( opacity: animation, child: child, diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index fae5643..a6716b0 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -40,7 +40,14 @@ class _Password2FAScreenState extends State { if (!mounted) return; - await accountModule.login(token: result.loginToken); + final loginResult = await accountModule.login(token: result.loginToken); + + if (!mounted) return; + + final avatar = await precacheLoginAvatar( + context, + loginResult.profile.baseUrl, + ); if (!mounted) return; @@ -48,7 +55,7 @@ class _Password2FAScreenState extends State { context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 240), - pageBuilder: (_, __, ___) => const LoginSuccessScreen(), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), transitionsBuilder: (_, animation, __, child) => FadeTransition( opacity: animation, child: child, diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 5f9c7d5..dd40169 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; import '../../../core/utils/haptics.dart'; @@ -30,8 +31,6 @@ class SettingsTab extends StatefulWidget { } class _SettingsTabState extends State { - static const bool _showLogoutButton = false; - ProfileData? _profile; bool _isPhoneVisible = false; String? _appVersionLabel; @@ -164,7 +163,7 @@ class _SettingsTabState extends State { ), const SizedBox(height: 8), Text( - 'Сессия будет сброшена. Локальный кеш сохранится — войдёшь снова в этот же аккаунт.', + 'Данные аккаунта будут удалены с этого устройства.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), const SizedBox(height: 20), @@ -202,8 +201,11 @@ class _SettingsTabState extends State { } catch (_) {} final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { - await TokenStorage.deleteToken(accountId); + await TokenStorage.deleteAccount(accountId); + await AppDatabase.deleteAccount(accountId); } + ContactCache.clear(); + TranscriptionCache.clear(); try { await api.connect(); } catch (_) {} @@ -442,6 +444,23 @@ child: _buildSection( ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSection( + context, + cs, + items: [ + _SettingsItem( + icon: Symbols.logout, + label: 'Выйти из аккаунта', + tintColor: cs.error, + onTap: _confirmLogout, + ), + ], + ), + ), + ), if (_appVersionLabel != null) SliverToBoxAdapter( child: Padding( @@ -552,51 +571,31 @@ child: _buildSection( ), ), const SizedBox(height: 4), - Stack( + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: _PhoneSpoiler( - text: phone, - isVisible: _isPhoneVisible, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - letterSpacing: 0.5, - ), - ), - ), - ), - const SizedBox(width: 4), - Icon( - _isPhoneVisible ? Symbols.visibility : Symbols.visibility_off, - size: 14, - color: cs.onSurfaceVariant.withValues(alpha: 0.6), - ), - ], - ), - if (_showLogoutButton) - Positioned.fill( - child: Align( - alignment: Alignment.centerRight, - child: IconButton( - tooltip: 'Выйти', - icon: Icon( - Symbols.logout, - color: cs.error, - size: 22, - weight: 400, - ), - onPressed: _confirmLogout, + GestureDetector( + onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: _PhoneSpoiler( + text: phone, + isVisible: _isPhoneVisible, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.5, ), ), ), + ), + const SizedBox(width: 4), + Icon( + _isPhoneVisible ? Symbols.visibility : Symbols.visibility_off, + size: 14, + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + ), ], ), ], @@ -662,7 +661,7 @@ child: _buildSection( children: [ Icon( item.icon, - color: cs.onSurfaceVariant, + color: item.tintColor ?? cs.onSurfaceVariant, size: 22, weight: 400, ), @@ -671,7 +670,7 @@ child: _buildSection( child: Text( item.label, style: TextStyle( - color: cs.onSurface, + color: item.tintColor ?? cs.onSurface, fontSize: 16, fontWeight: FontWeight.w500, ), @@ -712,6 +711,7 @@ class _SettingsItem { final IconData icon; final String label; final VoidCallback? onTap; + final Color? tintColor; /// When [onToggle] is set the row renders a trailing switch instead of a /// chevron, and [toggleValue] reflects its current state. @@ -722,6 +722,7 @@ class _SettingsItem { required this.icon, required this.label, this.onTap, + this.tintColor, this.toggleValue, this.onToggle, }); diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index 74aa9e9..fa35339 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -1,12 +1,29 @@ import 'dart:math' as math; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../core/utils/haptics.dart'; import '../screens/chats/chat_list_screen.dart'; +Future precacheLoginAvatar( + BuildContext context, + String? url, +) async { + if (url == null || url.isEmpty) return null; + final provider = CachedNetworkImageProvider(url); + try { + await precacheImage(provider, context); + return provider; + } catch (_) { + return null; + } +} + class LoginSuccessScreen extends StatefulWidget { - const LoginSuccessScreen({super.key}); + final ImageProvider? avatar; + + const LoginSuccessScreen({super.key, this.avatar}); @override State createState() => _LoginSuccessScreenState(); @@ -16,6 +33,13 @@ class _LoginSuccessScreenState extends State with SingleTickerProviderStateMixin { static const Duration _duration = Duration(milliseconds: 1900); + static const List _greetings = [ + 'С большой силой приходит большая ответственность', + 'All in your hands', + 'Иногда забавные вещи могут быть уголовно наказуемы', + ]; + + late final String _greeting; late final AnimationController _controller; late final Animation _circleScale; late final Animation _ringSweep; @@ -34,6 +58,7 @@ class _LoginSuccessScreenState extends State @override void initState() { super.initState(); + _greeting = _greetings[math.Random().nextInt(_greetings.length)]; _controller = AnimationController(vsync: this, duration: _duration); _circleScale = CurvedAnimation( @@ -227,6 +252,7 @@ class _LoginSuccessScreenState extends State Widget _buildCircle(ColorScheme cs) { final scale = _circleScale.value.clamp(0.0, 1.0); + final avatar = widget.avatar; return Transform.scale( scale: scale, child: Container( @@ -243,12 +269,21 @@ class _LoginSuccessScreenState extends State ), ], ), - child: CustomPaint( - painter: _CheckPainter( - progress: _checkProgress.value, - color: cs.onPrimary, - ), - ), + child: avatar != null + ? ClipOval( + child: Image( + image: avatar, + fit: BoxFit.cover, + width: 120, + height: 120, + ), + ) + : CustomPaint( + painter: _CheckPainter( + progress: _checkProgress.value, + color: cs.onPrimary, + ), + ), ), ); } @@ -276,12 +311,17 @@ class _LoginSuccessScreenState extends State opacity: _subtitleOpacity.value, child: Transform.translate( offset: Offset(0, 14 * (1 - _subtitleOffset.value)), - child: Text( - 'Добро пожаловать в Komet', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 15, - fontWeight: FontWeight.w500, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Text( + _greeting, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.3, + ), ), ), ), From 8a9832c85bbada366a7e9f749bfab0bdc609d51d Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 26 May 2026 17:32:29 +0300 Subject: [PATCH 157/323] =?UTF-8?q?feat:=20=D0=B4=D0=B5=D1=81=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D0=BF-=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=E2=80=94=202-pan?= =?UTF-8?q?e=20=D1=81=20=D1=80=D0=B5=D1=81=D0=B0=D0=B9=D0=B7-=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=B5=D0=BB=D0=B8=D1=82=D0=B5=D0=BB=D0=B5=D0=BC=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=88=D0=B8=D1=80=D0=B8=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=BA=D0=BD=D0=B0=20=E2=89=A5=20900px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 32 ++- lib/frontend/screens/chats/chat_screen.dart | 17 +- lib/frontend/widgets/adaptive_shell.dart | 220 ++++++++++++++++++ .../widgets/login_success_screen.dart | 4 +- lib/main.dart | 4 +- 5 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 lib/frontend/widgets/adaptive_shell.dart diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 60b4d53..ec16901 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -8,6 +8,7 @@ import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'chat_screen.dart'; import 'create_group_flow.dart'; +import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; import '../calls/calls_tab.dart'; @@ -56,7 +57,9 @@ class _StoriesScrollPhysics extends BouncingScrollPhysics { } class ChatListScreen extends StatefulWidget { - const ChatListScreen({super.key}); + final ValueChanged? onChatSelected; + + const ChatListScreen({super.key, this.onChatSelected}); @override State createState() => _ChatListScreenState(); @@ -2050,18 +2053,25 @@ class _ChatListScreenState extends State onTap: () { if (_isSelectionMode) { _toggleSelection(id); + } else if (widget.onChatSelected != null) { + widget.onChatSelected!(DesktopChatSelection( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, + )); } else { -Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatScreen( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, ), - ); + ), + ); } }, onLongPress: () => _toggleSelection(id), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 4b9fa90..4f26dce 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -58,6 +58,8 @@ class ChatScreen extends StatefulWidget { final String name; final String imageUrl; final String chatType; + final bool embedded; + final VoidCallback? onClose; const ChatScreen({ super.key, @@ -65,6 +67,8 @@ class ChatScreen extends StatefulWidget { required this.name, required this.imageUrl, required this.chatType, + this.embedded = false, + this.onClose, }); @override @@ -712,8 +716,17 @@ class _ChatScreenState extends State surfaceTintColor: Colors.transparent, iconTheme: IconThemeData(color: cs.onSurface), leading: IconButton( - icon: const Icon(Symbols.arrow_back, weight: 400), - onPressed: () => Navigator.pop(context), + icon: Icon( + widget.embedded ? Symbols.close : Symbols.arrow_back, + weight: 400, + ), + onPressed: () { + if (widget.embedded) { + widget.onClose?.call(); + } else { + Navigator.pop(context); + } + }, ), titleSpacing: 0, title: Row( diff --git a/lib/frontend/widgets/adaptive_shell.dart b/lib/frontend/widgets/adaptive_shell.dart new file mode 100644 index 0000000..c708845 --- /dev/null +++ b/lib/frontend/widgets/adaptive_shell.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../screens/chats/chat_list_screen.dart'; +import '../screens/chats/chat_screen.dart'; + +class AdaptiveShell extends StatefulWidget { + const AdaptiveShell({super.key}); + + @override + State createState() => _AdaptiveShellState(); +} + +class DesktopChatSelection { + final int chatId; + final String name; + final String imageUrl; + final String chatType; + + const DesktopChatSelection({ + required this.chatId, + required this.name, + required this.imageUrl, + required this.chatType, + }); +} + +class _AdaptiveShellState extends State { + static const double _breakpoint = 900; + static const double _defaultListWidth = 380; + static const double _minListWidth = 280; + static const double _maxListWidth = 560; + static const double _minChatPaneWidth = 360; + static const double _dividerHitWidth = 10; + static const double _dividerLineWidth = 1; + static const String _prefsKey = 'desktop_list_width'; + + double _listWidth = _defaultListWidth; + DesktopChatSelection? _selected; + + @override + void initState() { + super.initState(); + _loadListWidth(); + } + + Future _loadListWidth() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getDouble(_prefsKey); + if (saved == null || !mounted) return; + setState(() { + _listWidth = saved.clamp(_minListWidth, _maxListWidth); + }); + } + + Future _persistListWidth() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_prefsKey, _listWidth); + } + + void _onChatSelected(DesktopChatSelection chat) { + setState(() => _selected = chat); + } + + void _closeChat() { + setState(() => _selected = null); + } + + void _onDrag(double dx, double totalWidth) { + final maxAllowedByPane = + totalWidth - _minChatPaneWidth - _dividerHitWidth; + final upperBound = maxAllowedByPane < _maxListWidth + ? maxAllowedByPane + : _maxListWidth; + final lower = _minListWidth; + final next = (_listWidth + dx).clamp(lower, upperBound); + if (next == _listWidth) return; + setState(() => _listWidth = next); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < _breakpoint) { + return ChatListScreen(onChatSelected: _onChatSelected); + } + final totalWidth = constraints.maxWidth; + final effectiveListWidth = _listWidth.clamp( + _minListWidth, + (totalWidth - _minChatPaneWidth - _dividerHitWidth) + .clamp(_minListWidth, _maxListWidth), + ); + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: Row( + children: [ + SizedBox( + width: effectiveListWidth, + child: ChatListScreen(onChatSelected: _onChatSelected), + ), + _ResizeDivider( + hitWidth: _dividerHitWidth, + lineWidth: _dividerLineWidth, + color: cs.outlineVariant.withValues(alpha: 0.35), + onDrag: (dx) => _onDrag(dx, totalWidth), + onDragEnd: _persistListWidth, + ), + Expanded( + child: _selected == null + ? _EmptyChatPane(colorScheme: cs) + : ChatScreen( + key: ValueKey(_selected!.chatId), + chatId: _selected!.chatId, + name: _selected!.name, + imageUrl: _selected!.imageUrl, + chatType: _selected!.chatType, + embedded: true, + onClose: _closeChat, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _ResizeDivider extends StatefulWidget { + final double hitWidth; + final double lineWidth; + final Color color; + final ValueChanged onDrag; + final Future Function() onDragEnd; + + const _ResizeDivider({ + required this.hitWidth, + required this.lineWidth, + required this.color, + required this.onDrag, + required this.onDragEnd, + }); + + @override + State<_ResizeDivider> createState() => _ResizeDividerState(); +} + +class _ResizeDividerState extends State<_ResizeDivider> { + bool _hovering = false; + bool _dragging = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final highlight = _dragging || _hovering; + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) => setState(() => _dragging = true), + onHorizontalDragUpdate: (d) => widget.onDrag(d.delta.dx), + onHorizontalDragEnd: (_) async { + setState(() => _dragging = false); + await widget.onDragEnd(); + }, + onHorizontalDragCancel: () => setState(() => _dragging = false), + child: SizedBox( + width: widget.hitWidth, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + width: widget.lineWidth, + color: highlight ? cs.primary.withValues(alpha: 0.6) : widget.color, + ), + ), + ), + ), + ); + } +} + +class _EmptyChatPane extends StatelessWidget { + final ColorScheme colorScheme; + + const _EmptyChatPane({required this.colorScheme}); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: colorScheme.surfaceContainerLow, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.chat_bubble, + size: 56, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + weight: 300, + ), + const SizedBox(height: 14), + Text( + 'Выберите чат', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index fa35339..9617e37 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -4,7 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../core/utils/haptics.dart'; -import '../screens/chats/chat_list_screen.dart'; +import 'adaptive_shell.dart'; Future precacheLoginAvatar( BuildContext context, @@ -118,7 +118,7 @@ class _LoginSuccessScreenState extends State PageRouteBuilder( transitionDuration: const Duration(milliseconds: 360), reverseTransitionDuration: const Duration(milliseconds: 200), - pageBuilder: (_, __, ___) => const ChatListScreen(), + pageBuilder: (_, __, ___) => const AdaptiveShell(), transitionsBuilder: (_, animation, __, child) { return FadeTransition( opacity: CurvedAnimation( diff --git a/lib/main.dart b/lib/main.dart index cbf9d76..74485e1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,7 +33,7 @@ import 'core/utils/haptics.dart'; import 'core/protocol/packet.dart'; import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; -import 'frontend/screens/chats/chat_list_screen.dart'; +import 'frontend/widgets/adaptive_shell.dart'; import 'frontend/widgets/custom_notification.dart'; import 'frontend/widgets/theme_reveal.dart'; @@ -694,7 +694,7 @@ class _StartupScreenState extends State<_StartupScreen> { if (mounted) { Navigator.pushReplacement( context, - MaterialPageRoute(builder: (_) => const ChatListScreen()), + MaterialPageRoute(builder: (_) => const AdaptiveShell()), ); } } From 19b7d4562ad1f23e43d9c50ee7fd6bbfce35c9a7 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 26 May 2026 18:09:29 +0300 Subject: [PATCH 158/323] =?UTF-8?q?feat:=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B8=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=E2=80=94=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=B8?= =?UTF-8?q?=D0=BC=20=D0=BD=D0=B0=D0=B6=D0=B0=D1=82=D0=B8=D0=B5=D0=BC=20?= =?UTF-8?q?=D0=BD=D0=B0=20=C2=AB=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 21 + lib/frontend/screens/auth/login_screen.dart | 48 +- .../screens/chats/chat_list_screen.dart | 39 ++ .../widgets/account_switcher_overlay.dart | 490 ++++++++++++++++++ 4 files changed, 582 insertions(+), 16 deletions(-) create mode 100644 lib/frontend/widgets/account_switcher_overlay.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 29c5661..67573bb 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -9,6 +9,7 @@ import '../../core/utils/logger.dart'; import 'chats.dart'; import 'contacts.dart'; import 'folders.dart'; +import 'messages.dart'; String _normalizeAuthPhone(String phone) { final digits = phone.replaceAll(RegExp(r'\D'), ''); @@ -925,14 +926,34 @@ class AccountModule { if (profile == null) { throw StateError('switchAccount: аккаунт $accountId не найден в базе'); } + final token = await TokenStorage.readToken(accountId); + if (token == null) { + throw StateError('switchAccount: нет токена для аккаунта $accountId'); + } + + try { + await _api.disconnect(); + } catch (_) {} await AppDatabase.setActiveAccount(accountId); await TokenStorage.setActiveAccount(accountId); + ContactCache.clear(); + TranscriptionCache.clear(); + await ContactsModule.primeCacheFromDb(accountId); + + try { + await _api.connect(); + } catch (_) {} + logger.i('Активный аккаунт переключён на $accountId'); return profile; } + Future> listAccounts() async { + return AppDatabase.loadAllProfiles(); + } + Future removeAccount(int accountId) async { await AppDatabase.deleteAccount(accountId); await TokenStorage.deleteAccount(accountId); diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index c64d751..037a5c8 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -664,23 +664,39 @@ class _LoginScreenState extends State { children: [ const SizedBox(height: 44), Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton( - onPressed: () => _showSecurityOptions(context), - icon: Icon( - Symbols.admin_panel_settings, - color: cs.onSurfaceVariant, - weight: 400, - ), - ), - IconButton( - onPressed: _showLanguagePicker, - icon: Icon( - Symbols.language, - color: cs.onSurfaceVariant, - weight: 400, - ), + if (Navigator.canPop(context)) + IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon( + Symbols.arrow_back, + color: cs.onSurfaceVariant, + weight: 400, + ), + ) + else + const SizedBox.shrink(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => _showSecurityOptions(context), + icon: Icon( + Symbols.admin_panel_settings, + color: cs.onSurfaceVariant, + weight: 400, + ), + ), + IconButton( + onPressed: _showLanguagePicker, + icon: Icon( + Symbols.language, + color: cs.onSurfaceVariant, + weight: 400, + ), + ), + ], ), ], ), diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index ec16901..d4f1347 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -14,6 +14,8 @@ import '../../widgets/custom_notification.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; import '../profile/settings_tab.dart'; +import '../auth/login_screen.dart'; +import '../../widgets/account_switcher_overlay.dart'; import '../../../backend/api.dart'; import '../../../core/utils/haptics.dart'; import '../../../backend/models/chat_folder.dart'; @@ -2285,8 +2287,12 @@ class _ChatListScreenState extends State final Duration opacityDur = instant ? Duration.zero : const Duration(milliseconds: 200); + final bool isSettings = index == 3; return GestureDetector( onTap: () => _onNavTabSelected(index), + onLongPressStart: isSettings + ? (details) => _openAccountSwitcher(details.globalPosition) + : null, behavior: HitTestBehavior.opaque, child: Center( child: FittedBox( @@ -2330,6 +2336,39 @@ class _ChatListScreenState extends State ); } + void _openAccountSwitcher(Offset point) { + Haptics.medium(); + final controller = AccountSwitcherController()..attach(point); + showAccountSwitcher( + context: context, + tapPoint: point, + controller: controller, + onSelected: (accountId) async { + controller.dispose(); + if (!mounted) return; + if (accountId == null) { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + return; + } + try { + await accountModule.switchAccount(accountId); + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось переключить аккаунт'); + return; + } + if (!mounted) return; + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const AdaptiveShell()), + (route) => false, + ); + }, + ); + } + Widget _buildFabMenu() { return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/frontend/widgets/account_switcher_overlay.dart b/lib/frontend/widgets/account_switcher_overlay.dart new file mode 100644 index 0000000..983692c --- /dev/null +++ b/lib/frontend/widgets/account_switcher_overlay.dart @@ -0,0 +1,490 @@ +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; +import '../../core/utils/haptics.dart'; + +class AccountSwitcherController extends ChangeNotifier { + Offset? pointer; + Offset? initialPointer; + bool committed = false; + bool movedSignificantly = false; + bool _attached = false; + + void attach(Offset initial) { + if (_attached) return; + _attached = true; + initialPointer = initial; + pointer = initial; + GestureBinding.instance.pointerRouter.addGlobalRoute(_onPointerEvent); + } + + void _onPointerEvent(PointerEvent event) { + if (committed) return; + if (event is PointerMoveEvent) { + pointer = event.position; + if (initialPointer != null && + !movedSignificantly && + (event.position - initialPointer!).distance > 12) { + movedSignificantly = true; + } + notifyListeners(); + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + commit(); + } + } + + void commit() { + if (committed) return; + committed = true; + notifyListeners(); + } + + @override + void dispose() { + if (_attached) { + GestureBinding.instance.pointerRouter.removeGlobalRoute(_onPointerEvent); + _attached = false; + } + super.dispose(); + } +} + +typedef AccountSwitcherCallback = void Function(int? accountId); + +void showAccountSwitcher({ + required BuildContext context, + required Offset tapPoint, + required AccountSwitcherController controller, + required AccountSwitcherCallback onSelected, +}) { + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => _AccountSwitcherLayer( + tapPoint: tapPoint, + controller: controller, + onSelected: onSelected, + onDismiss: () { + if (entry.mounted) entry.remove(); + }, + ), + ); + overlay.insert(entry); +} + +class _AccountSwitcherLayer extends StatefulWidget { + final Offset tapPoint; + final AccountSwitcherController controller; + final AccountSwitcherCallback onSelected; + final VoidCallback onDismiss; + + const _AccountSwitcherLayer({ + required this.tapPoint, + required this.controller, + required this.onSelected, + required this.onDismiss, + }); + + @override + State<_AccountSwitcherLayer> createState() => _AccountSwitcherLayerState(); +} + +class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer> + with SingleTickerProviderStateMixin { + static const double _menuWidth = 280.0; + static const double _itemHeight = 60.0; + static const double _addItemHeight = 54.0; + static const double _vPad = 8.0; + static const double _hMargin = 12.0; + + late final AnimationController _animController; + late final Animation _animation; + bool _closing = false; + + List _accounts = const []; + int? _activeId; + bool _loaded = false; + + int _hoveredIndex = -1; + Rect _menuRect = Rect.zero; + List _itemHitRects = const []; + bool _committedFired = false; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + reverseDuration: const Duration(milliseconds: 180), + ); + _animation = CurvedAnimation( + parent: _animController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + _animController.forward(); + widget.controller.addListener(_onControllerUpdate); + _loadAccounts(); + } + + Future _loadAccounts() async { + final accounts = await AppDatabase.loadAllProfiles(); + final activeId = await TokenStorage.getActiveAccountId(); + if (!mounted) return; + setState(() { + _accounts = accounts; + _activeId = activeId; + _loaded = true; + _computeGeometry(); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _onControllerUpdate(); + }); + } + + void _computeGeometry() { + final screen = MediaQuery.sizeOf(context); + final totalItems = _accounts.length; + final height = _vPad * 2 + totalItems * _itemHeight + _addItemHeight; + + double menuX = widget.tapPoint.dx - _menuWidth / 2; + menuX = menuX.clamp(_hMargin, screen.width - _menuWidth - _hMargin); + + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final maxBottom = screen.height - bottomInset - 88; + double menuBottom = maxBottom; + if (widget.tapPoint.dy - 20 < menuBottom) { + menuBottom = widget.tapPoint.dy - 20; + } + double menuY = menuBottom - height; + if (menuY < 24) menuY = 24; + + _menuRect = Rect.fromLTWH(menuX, menuY, _menuWidth, height); + _itemHitRects = [ + for (int i = 0; i < totalItems; i++) + Rect.fromLTWH( + menuX, + menuY + _vPad + i * _itemHeight, + _menuWidth, + _itemHeight, + ), + Rect.fromLTWH( + menuX, + menuY + _vPad + totalItems * _itemHeight, + _menuWidth, + _addItemHeight, + ), + ]; + } + + void _onControllerUpdate() { + if (!mounted || !_loaded) return; + final p = widget.controller.pointer; + if (p != null) { + final newHovered = _findItemAt(p); + if (newHovered != _hoveredIndex) { + if (newHovered != -1) Haptics.selection(); + setState(() => _hoveredIndex = newHovered); + } + } + if (widget.controller.committed && !_committedFired) { + _committedFired = true; + _onCommit(); + } + } + + int _findItemAt(Offset p) { + for (int i = 0; i < _itemHitRects.length; i++) { + if (_itemHitRects[i].contains(p)) return i; + } + return -1; + } + + void _onCommit() { + if (_hoveredIndex == -1) { + _close(); + return; + } + Haptics.medium(); + final isAddItem = _hoveredIndex == _accounts.length; + final id = isAddItem ? null : _accounts[_hoveredIndex].id; + if (!isAddItem && id == _activeId) { + _close(); + return; + } + final selected = id; + _close().then((_) => widget.onSelected(selected)); + } + + Future _close() async { + if (!mounted || _closing) return; + _closing = true; + try { + await _animController.reverse(); + } catch (_) {} + if (!mounted) return; + widget.onDismiss(); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerUpdate); + _animController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (ctx, _) { + final t = _animation.value.clamp(0.0, 1.0); + final blurSigma = 14.0 * t; + return GestureDetector( + onTap: _close, + behavior: HitTestBehavior.opaque, + child: Stack( + children: [ + Positioned.fill( + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.22 * t), + ), + ), + ), + if (_loaded) _buildMenu(t), + ], + ), + ); + }, + ); + } + + Widget _buildMenu(double t) { + final cs = Theme.of(context).colorScheme; + final eased = Curves.easeOutCubic.transform(t); + final scale = 0.88 + 0.12 * eased; + return Positioned( + left: _menuRect.left, + top: _menuRect.top, + width: _menuRect.width, + height: _menuRect.height, + child: Opacity( + opacity: eased, + child: Transform.scale( + scale: scale, + alignment: Alignment.bottomCenter, + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(22), + clipBehavior: Clip.antiAlias, + elevation: 10, + shadowColor: Colors.black.withValues(alpha: 0.4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: _vPad), + for (int i = 0; i < _accounts.length; i++) + _AccountRow( + profile: _accounts[i], + highlighted: _hoveredIndex == i, + active: _accounts[i].id == _activeId, + ), + _AddAccountRow( + highlighted: _hoveredIndex == _accounts.length, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _AccountRow extends StatelessWidget { + final ProfileData profile; + final bool highlighted; + final bool active; + + const _AccountRow({ + required this.profile, + required this.highlighted, + required this.active, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final pillBg = cs.primary; + final onPill = cs.onPrimary; + final fg = highlighted ? onPill : cs.onSurface; + final subFg = highlighted + ? onPill.withValues(alpha: 0.8) + : cs.onSurfaceVariant; + final fullName = (profile.lastName != null && profile.lastName!.isNotEmpty) + ? '${profile.firstName} ${profile.lastName}' + : profile.firstName; + final phone = profile.phone == 0 ? '' : '+${profile.phone}'; + return SizedBox( + height: 60, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: highlighted ? pillBg : Colors.transparent, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: active + ? Border.all( + color: highlighted ? onPill : cs.primary, + width: 2, + ) + : null, + ), + child: ClipOval( + child: profile.baseUrl != null && profile.baseUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: profile.baseUrl!, + fit: BoxFit.cover, + memCacheWidth: 96, + memCacheHeight: 96, + errorWidget: (_, __, ___) => + _initialAvatar(cs, fullName, highlighted), + ) + : _initialAvatar(cs, fullName, highlighted), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + fullName.isNotEmpty ? fullName : 'Без имени', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: fg, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (phone.isNotEmpty) + Text( + phone, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: subFg, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + if (active) + Icon( + Symbols.check_circle, + color: highlighted ? onPill : cs.primary, + size: 20, + ), + ], + ), + ), + ), + ); + } + + Widget _initialAvatar(ColorScheme cs, String name, bool highlighted) { + return Container( + color: highlighted ? cs.primaryContainer : cs.surfaceContainerHighest, + alignment: Alignment.center, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _AddAccountRow extends StatelessWidget { + final bool highlighted; + + const _AddAccountRow({required this.highlighted}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final pillBg = cs.primary; + final onPill = cs.onPrimary; + final fg = highlighted ? onPill : cs.primary; + return SizedBox( + height: 54, + child: Padding( + padding: const EdgeInsets.fromLTRB(6, 0, 6, 6), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + decoration: BoxDecoration( + color: highlighted ? pillBg : Colors.transparent, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: highlighted + ? onPill.withValues(alpha: 0.18) + : cs.primary.withValues(alpha: 0.12), + ), + alignment: Alignment.center, + child: Icon(Symbols.add, color: fg, size: 20, weight: 500), + ), + const SizedBox(width: 14), + Text( + 'Добавить аккаунт', + style: TextStyle( + color: fg, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} From d552623b4b85c99bc3e2ef31be95bb0b5c35e027 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 27 May 2026 20:54:08 +0700 Subject: [PATCH 159/323] =?UTF-8?q?=D1=81=D1=83=D0=BA=D0=B0=20=D1=83=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=87=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B1=D0=BB=D1=8F=D1=82=D1=8C=20=D0=B7=D0=B0=D0=B1?= =?UTF-8?q?=D0=B8=D0=BB=D0=B4=D0=B8=D1=82=D0=B5=20=D0=BC=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B0=D0=BF=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/NOTICE | 190 +++ android/app/src/main/AndroidManifest.xml | 6 + .../main/kotlin/ru/komet/app/MainActivity.kt | 44 + .../ru/komet/app/UploadForegroundService.kt | 95 ++ lib/backend/modules/chats.dart | 48 + lib/backend/modules/cloud_storage.dart | 209 ++++ lib/backend/modules/upload_manager.dart | 103 ++ .../modules/upload_notification_service.dart | 31 + .../screens/chats/chat_list_screen.dart | 3 +- .../screens/profile/cloud_storage_screen.dart | 1088 +++++++++++++---- 10 files changed, 1580 insertions(+), 237 deletions(-) create mode 100755 android/NOTICE create mode 100644 android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt create mode 100644 lib/backend/modules/cloud_storage.dart create mode 100644 lib/backend/modules/upload_manager.dart create mode 100644 lib/backend/modules/upload_notification_service.dart diff --git a/android/NOTICE b/android/NOTICE new file mode 100755 index 0000000..06a9081 --- /dev/null +++ b/android/NOTICE @@ -0,0 +1,190 @@ + + Copyright (c) 2005-2014, The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8dd5624..a3b1ba5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + + diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 2aeadb5..dfd5d77 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -1,10 +1,12 @@ package ru.komet.app import android.content.Context +import android.content.Intent import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest +import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log @@ -36,6 +38,48 @@ class MainActivity : FlutterActivity() { else -> result.notImplemented() } } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/upload_service", + ).setMethodCallHandler { call, result -> + val ctx = this + when (call.method) { + "start" -> { + val filename = call.argument("filename") ?: "Файл" + val intent = Intent(ctx, UploadForegroundService::class.java).apply { + action = UploadForegroundService.ACTION_START + putExtra(UploadForegroundService.EXTRA_FILENAME, filename) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + result.success(null) + } + "update" -> { + val filename = call.argument("filename") ?: "Файл" + val progress = call.argument("progress") ?: 0 + val speed = call.argument("speed") ?: 0L + val intent = Intent(ctx, UploadForegroundService::class.java).apply { + action = UploadForegroundService.ACTION_UPDATE + putExtra(UploadForegroundService.EXTRA_FILENAME, filename) + putExtra(UploadForegroundService.EXTRA_PROGRESS, progress) + putExtra(UploadForegroundService.EXTRA_SPEED, speed) + } + startService(intent) + result.success(null) + } + "stop" -> { + startService(Intent(ctx, UploadForegroundService::class.java).apply { + action = UploadForegroundService.ACTION_STOP + }) + result.success(null) + } + else -> result.notImplemented() + } + } } private fun connectivityManager(): ConnectivityManager = diff --git a/android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt b/android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt new file mode 100644 index 0000000..3bd7698 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/UploadForegroundService.kt @@ -0,0 +1,95 @@ +package ru.komet.app + +import android.app.* +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat + +class UploadForegroundService : Service() { + + companion object { + const val CHANNEL_ID = "komet_upload" + const val NOTIFICATION_ID = 9001 + const val ACTION_START = "ru.komet.app.UPLOAD_START" + const val ACTION_UPDATE = "ru.komet.app.UPLOAD_UPDATE" + const val ACTION_STOP = "ru.komet.app.UPLOAD_STOP" + const val EXTRA_FILENAME = "filename" + const val EXTRA_PROGRESS = "progress" // 0-100 + const val EXTRA_SPEED = "speed" // bytes/sec (Long) + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> { + val filename = intent.getStringExtra(EXTRA_FILENAME) ?: "Файл" + val notification = buildNotification(filename, 0, 0, indeterminate = true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + ACTION_UPDATE -> { + val filename = intent.getStringExtra(EXTRA_FILENAME) ?: "Файл" + val progress = intent.getIntExtra(EXTRA_PROGRESS, 0) + val speed = intent.getLongExtra(EXTRA_SPEED, 0L) + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NOTIFICATION_ID, buildNotification(filename, progress, speed)) + } + ACTION_STOP -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + return START_NOT_STICKY + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Загрузка файлов", + NotificationManager.IMPORTANCE_LOW + ).apply { setShowBadge(false) } + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + } + + private fun buildNotification( + filename: String, + progress: Int, + speedBps: Long, + indeterminate: Boolean = false + ): Notification { + val body = when { + indeterminate -> "Подготовка..." + speedBps > 0 -> "$progress% · ${formatSpeed(speedBps)}" + else -> "$progress%" + } + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setContentTitle(filename) + .setContentText(body) + .setProgress(100, progress, indeterminate) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .build() + } + + private fun formatSpeed(bps: Long): String = when { + bps < 1_024L -> "$bps Б/с" + bps < 1_048_576L -> "${bps / 1024} КБ/с" + else -> "${"%.1f".format(bps / 1_048_576.0)} МБ/с" + } +} diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 5cc7cca..96387e6 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -661,6 +661,40 @@ class ChatsModule { return packet.isOk; } + static Future setChatOptions( + Api api, { + required int chatId, + required Map options, + }) async { + final packet = await api.sendRequest(Opcode.chatUpdate, { + 'chatId': chatId, + 'options': options, + }); + return packet.isOk; + } + + static Future setChatTitle( + Api api, { + required int chatId, + required String title, + }) async { + final packet = await api.sendRequest(Opcode.chatUpdate, { + 'chatId': chatId, + 'theme': title, + }); + if (!packet.isOk) return false; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return true; + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isNotEmpty) { + final updated = Map.from(rows.first); + updated['title'] = title; + await AppDatabase.saveChats([updated]); + _bump(); + } + return true; + } + static Future togglePin( Api api, { required List chatIds, @@ -777,6 +811,20 @@ class ChatsModule { } } + static Future leaveChat(Api api, {required int chatId}) async { + try { + await api.sendRequest(Opcode.chatLeave, {'chatId': chatId}); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.deleteChat(chatId, accountId); + _bump(); + } + return true; + } catch (_) { + return false; + } + } + static Future> refreshChats( Api api, List chatIds, diff --git a/lib/backend/modules/cloud_storage.dart b/lib/backend/modules/cloud_storage.dart new file mode 100644 index 0000000..9b173fe --- /dev/null +++ b/lib/backend/modules/cloud_storage.dart @@ -0,0 +1,209 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../models/attachment.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../api.dart'; +import 'chats.dart'; +import 'messages.dart'; + +class CloudFile { + final String name; + final int? size; + final int time; + final int? fileId; + final String messageId; + final int chatId; + final int accountId; + + const CloudFile({ + required this.name, + this.size, + required this.time, + this.fileId, + required this.messageId, + required this.chatId, + required this.accountId, + }); +} + +class CloudStorageModule { + static const _prefix = 'CLST'; + static const _tempName = 'Облачное хранилище'; + + // Key: "$accountId:$fileId" — scoped per account + static final Map _linkCache = {}; + + static int _computeSpecialNumber(int groupId) { + final s = groupId.abs().toString(); + final len = s.length; + if (len < 4) { + final n = int.parse(s); + return n + n; + } + final first = int.parse(s.substring(0, 4)); + final last = int.parse(s.substring(len - 4)); + return first + last; + } + + static bool isCloudStorageGroup(CachedChat chat) { + if (chat.type != 'CHAT') return false; + final title = chat.title; + if (title == null || !title.startsWith(_prefix)) return false; + final numStr = title.substring(_prefix.length); + final provided = int.tryParse(numStr); + if (provided == null) return false; + return provided == _computeSpecialNumber(chat.id); + } + + static CachedChat? findEnvGroup(List chats) { + for (final c in chats) { + if (isCloudStorageGroup(c)) return c; + } + return null; + } + + static List findOrphanGroups(List chats) => + chats.where((c) => c.type == 'CHAT' && c.title == _tempName).toList(); + + // Env group ID cache — avoids scanning all chats on every screen open + static Future cacheEnvGroupId(int accountId, int groupId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('cloud_storage_env_$accountId', groupId); + } + + static Future getCachedEnvGroupId(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('cloud_storage_env_$accountId'); + } + + static Future clearEnvGroupCache(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('cloud_storage_env_$accountId'); + } + + static Future _configurePrivacy(Api api, int chatId) async { + await Future.wait([ + ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_OWNER_CAN_CHANGE_ICON_TITLE': true}), + ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_ADMIN_CAN_ADD_MEMBER': true}), + ChatsModule.setChatOptions(api, chatId: chatId, options: {'ALL_CAN_PIN_MESSAGE': false}), + ChatsModule.setChatOptions(api, chatId: chatId, options: {'ONLY_ADMIN_CAN_CALL': true}), + ]); + } + + static Future setupEnv(Api api) async { + final temp = await ChatsModule.createGroupChat( + api, + title: _tempName, + userIds: [], + ); + if (temp == null) return null; + final name = '$_prefix${_computeSpecialNumber(temp.id)}'; + final ok = await ChatsModule.setChatTitle(api, chatId: temp.id, title: name); + if (!ok) return null; + await _configurePrivacy(api, temp.id); + return temp; + } + + // Turns an orphan "Облачное хранилище" group into a valid env group + static Future repairOrphan(Api api, CachedChat orphan) async { + final name = '$_prefix${_computeSpecialNumber(orphan.id)}'; + final ok = await ChatsModule.setChatTitle(api, chatId: orphan.id, title: name); + if (!ok) return null; + await _configurePrivacy(api, orphan.id); + return orphan; + } + + static Future> fetchFiles( + MessagesModule messages, + int accountId, + int chatId, { + int count = 200, + }) async { + final msgs = await messages.fetchHistory(accountId, chatId, count: count); + final files = []; + for (final msg in msgs) { + for (final a in msg.attachments ?? []) { + if (a is FileAttachment && a.name != null) { + files.add(CloudFile( + name: a.name!, + size: a.size, + time: msg.time, + fileId: a.fileId, + messageId: msg.id, + chatId: chatId, + accountId: accountId, + )); + } + } + } + return files; + } + + // Fetches only the last few messages to find a newly uploaded file — avoids full 200-msg reload + static Future fetchLatestFile( + MessagesModule messages, + int accountId, + int chatId, { + int? expectedFileId, + }) async { + final msgs = await messages.fetchHistory(accountId, chatId, count: 5); + for (final msg in msgs) { + for (final a in msg.attachments ?? []) { + if (a is FileAttachment && a.name != null) { + if (expectedFileId == null || a.fileId == expectedFileId) { + return CloudFile( + name: a.name!, + size: a.size, + time: msg.time, + fileId: a.fileId, + messageId: msg.id, + chatId: chatId, + accountId: accountId, + ); + } + } + } + } + return null; + } + + static ({String url, int expires})? getCachedLink(int accountId, int fileId) { + final key = '$accountId:$fileId'; + final entry = _linkCache[key]; + if (entry == null) return null; + if (entry.expires <= DateTime.now().millisecondsSinceEpoch) { + _linkCache.remove(key); + return null; + } + return entry; + } + + static Future<({String url, int expires})?> fetchFileUrl( + Api api, { + required int accountId, + required int fileId, + required int chatId, + required String messageId, + }) async { + try { + final packet = await api.sendRequest(Opcode.fileDownload, { + 'fileId': fileId, + 'chatId': chatId, + 'messageId': int.tryParse(messageId) ?? messageId, + }); + if (!packet.isOk) return null; + final data = packet.payload; + if (data is! Map) return null; + final url = data['url'] as String?; + if (url == null) return null; + final uri = Uri.tryParse(url); + final expiresStr = uri?.queryParameters['expires']; + final expires = int.tryParse(expiresStr ?? '') ?? 0; + final entry = (url: url, expires: expires); + _linkCache['$accountId:$fileId'] = entry; + return entry; + } catch (_) { + return null; + } + } +} diff --git a/lib/backend/modules/upload_manager.dart b/lib/backend/modules/upload_manager.dart new file mode 100644 index 0000000..1c0c9c4 --- /dev/null +++ b/lib/backend/modules/upload_manager.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +import 'dart:io'; + +import '../../main.dart'; +import 'cloud_storage.dart'; +import 'file_uploader.dart'; +import 'upload_notification_service.dart'; + +class UploadManager { + UploadManager._(); + static final instance = UploadManager._(); + + StreamSubscription? _sub; + bool get isActive => _sub != null; + + // UI callbacks — registered by the screen while it is mounted + void Function(double progress, int speedBps)? onProgress; + void Function(CloudFile file)? onDone; + void Function(String error)? onError; + + Future start({ + required int chatId, + required int accountId, + required File file, + required String filename, + required int totalSize, + }) async { + await cancel(); // cancel any previous upload + + await UploadNotificationService.start(filename); + + var lastSentBytes = 0; + var lastSpeedMs = DateTime.now().millisecondsSinceEpoch; + var speedBps = 0; + var lastNotifPercent = -1; + + _sub = fileUploader + .upload( + chatId: chatId, + file: file, + filename: filename, + totalSize: totalSize, + ) + .listen( + (event) async { + switch (event) { + case UploadProgress(:final sent, :final total): + final progress = total > 0 ? sent / total : 0.0; + + // Speed: recompute every 500 ms + final nowMs = DateTime.now().millisecondsSinceEpoch; + final elapsed = nowMs - lastSpeedMs; + if (elapsed >= 500) { + speedBps = ((sent - lastSentBytes) * 1000 / elapsed).round(); + lastSentBytes = sent; + lastSpeedMs = nowMs; + } + + onProgress?.call(progress, speedBps); + + // Throttle notification to once per 1% change + final percent = total > 0 ? (sent * 100 ~/ total) : 0; + if (percent != lastNotifPercent) { + lastNotifPercent = percent; + UploadNotificationService.update( + filename: filename, + progressPercent: percent, + speedBps: speedBps, + ); + } + + case UploadDone(:final fileId): + _sub = null; + UploadNotificationService.stop(); + final newest = await CloudStorageModule.fetchLatestFile( + messagesModule, + accountId, + chatId, + expectedFileId: fileId, + ); + if (newest != null) { + onDone?.call(newest); + } + + case UploadError(:final message): + _sub = null; + UploadNotificationService.stop(); + onError?.call(message); + } + }, + onError: (_) { + _sub = null; + UploadNotificationService.stop(); + }, + ); + } + + Future cancel() async { + await _sub?.cancel(); + _sub = null; + await UploadNotificationService.stop(); + } +} diff --git a/lib/backend/modules/upload_notification_service.dart b/lib/backend/modules/upload_notification_service.dart new file mode 100644 index 0000000..180370f --- /dev/null +++ b/lib/backend/modules/upload_notification_service.dart @@ -0,0 +1,31 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; + +class UploadNotificationService { + static const _ch = MethodChannel('ru.komet.app/upload_service'); + + static Future start(String filename) async { + if (!Platform.isAndroid) return; + try { await _ch.invokeMethod('start', {'filename': filename}); } catch (_) {} + } + + static Future update({ + required String filename, + required int progressPercent, + required int speedBps, + }) async { + if (!Platform.isAndroid) return; + try { + await _ch.invokeMethod('update', { + 'filename': filename, + 'progress': progressPercent, + 'speed': speedBps, + }); + } catch (_) {} + } + + static Future stop() async { + if (!Platform.isAndroid) return; + try { await _ch.invokeMethod('stop'); } catch (_) {} + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 60b4d53..08ce89e 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -18,6 +18,7 @@ import '../../../core/utils/haptics.dart'; import '../../../backend/models/chat_folder.dart'; import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; +import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; import '../../../main.dart' show accountModule, api, messagesModule; @@ -503,7 +504,7 @@ class _ChatListScreenState extends State if (mounted) { setState(() { _profile = p; - _chats = chats; + _chats = chats.where((c) => !CloudStorageModule.isCloudStorageGroup(c)).toList(); _folders = folders; _foldersListKnown = foldersKnown; if (_selectedFolderId != null && diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index d49ccd9..b776637 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -1,6 +1,21 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../backend/modules/chats.dart'; +import '../../../backend/modules/cloud_storage.dart'; +import '../../../backend/modules/upload_manager.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; + +enum _EnvState { loading, notConfigured, ready } + class CloudStorageScreen extends StatefulWidget { const CloudStorageScreen({super.key}); @@ -8,35 +23,257 @@ class CloudStorageScreen extends StatefulWidget { State createState() => _CloudStorageScreenState(); } -class _CloudStorageScreenState extends State - with SingleTickerProviderStateMixin { - static const _translateFactor = 0.7; +class _CloudStorageScreenState extends State { static const _horizontalPadding = 32.0; - static const _hintSidePadding = 35.0; - static const _cornerSidePadding = 16.0; - static const _cornerBottomPadding = 24.0; - static const _cornerSlideAmount = 28.0; + static const _cardViewportFraction = 0.38; - late final _UploadModeController _mode; + late final PageController _pageController; + final _currentFilePage = ValueNotifier(0); + + _EnvState _envState = _EnvState.loading; + bool _isCreatingEnv = false; + int? _envGroupId; + int? _accountId; + List _files = []; + bool _filesLoading = false; + bool _isUploading = false; + double _uploadProgress = 0; + bool _animateNewCard = false; + bool _showUploadCard = false; @override void initState() { super.initState(); - _mode = _UploadModeController(this); + _pageController = PageController(viewportFraction: _cardViewportFraction); + _pageController.addListener(_onPageScroll); + _checkEnv(); + _bindUploadManager(); + } + + void _bindUploadManager() { + final mgr = UploadManager.instance; + if (mgr.isActive) { + // Upload was already running (e.g. user left and came back) + setState(() { _isUploading = true; _showUploadCard = true; }); + } + mgr.onProgress = (progress, speedBps) { + if (!mounted) return; + setState(() { _isUploading = true; _uploadProgress = progress; }); + }; + mgr.onDone = (file) { + if (!mounted) return; + setState(() { _isUploading = false; _uploadProgress = 0; }); + _prependFile(file); + }; + mgr.onError = (msg) { + if (!mounted) return; + setState(() { _isUploading = false; _uploadProgress = 0; }); + showCustomNotification(context, 'Ошибка: $msg'); + }; + } + + void _onPageScroll() { + _currentFilePage.value = _pageController.page?.round() ?? 0; } @override void dispose() { - _mode.dispose(); + // Unregister UI callbacks — upload continues in background via UploadManager + final mgr = UploadManager.instance; + mgr.onProgress = null; + mgr.onDone = null; + mgr.onError = null; + _pageController.dispose(); + _currentFilePage.dispose(); super.dispose(); } - void _onBack() { - if (_mode.isOpen) { - _mode.close(); - } else { - Navigator.pop(context); + Future _checkEnv() async { + final profile = await AppDatabase.loadActiveProfile(); + if (profile == null) { + if (mounted) setState(() => _envState = _EnvState.notConfigured); + return; } + + // Fast path: validate cached env group ID with a single DB row query + final cachedId = await CloudStorageModule.getCachedEnvGroupId(profile.id); + if (cachedId != null) { + final rows = await ChatsModule.getChat(profile.id, cachedId); + if (rows.isNotEmpty && CloudStorageModule.isCloudStorageGroup(rows.first)) { + if (!mounted) return; + setState(() { + _envState = _EnvState.ready; + _envGroupId = cachedId; + _accountId = profile.id; + }); + _loadFiles(profile.id, cachedId); + _handleOrphansBackground(profile.id, cachedId); + return; + } + await CloudStorageModule.clearEnvGroupCache(profile.id); + } + + // Full scan + final chats = await ChatsModule.getChats(profile.id); + CachedChat? envGroup = CloudStorageModule.findEnvGroup(chats); + final orphans = CloudStorageModule.findOrphanGroups(chats); + + if (envGroup == null && orphans.isNotEmpty) { + // Repair first orphan into a valid env group + final repaired = await CloudStorageModule.repairOrphan(api, orphans.first); + if (repaired != null) { + envGroup = repaired; + await CloudStorageModule.cacheEnvGroupId(profile.id, repaired.id); + } + // If there were multiple orphans, clean up the rest + for (final orphan in orphans.skip(1)) { + _deleteOrLeave(profile.id, orphan); + } + } else if (envGroup != null) { + await CloudStorageModule.cacheEnvGroupId(profile.id, envGroup.id); + // Env already exists — delete or leave any orphan groups + for (final orphan in orphans) { + _deleteOrLeave(profile.id, orphan); + } + } + + if (!mounted) return; + setState(() { + _envState = envGroup != null ? _EnvState.ready : _EnvState.notConfigured; + _envGroupId = envGroup?.id; + _accountId = profile.id; + }); + if (envGroup != null) _loadFiles(profile.id, envGroup.id); + } + + // Runs in background after the fast-cache path — loads all chats to find and remove orphans + void _handleOrphansBackground(int accountId, int envGroupId) async { + final chats = await ChatsModule.getChats(accountId); + final orphans = CloudStorageModule.findOrphanGroups(chats); + for (final orphan in orphans) { + _deleteOrLeave(accountId, orphan); + } + } + + void _deleteOrLeave(int accountId, CachedChat chat) async { + final isAdmin = chat.owner == accountId || chat.admins.contains(accountId); + if (isAdmin) { + await ChatsModule.deleteChat(api, chatId: chat.id, lastEventTime: chat.lastEventTime, forAll: true); + } else { + await ChatsModule.leaveChat(api, chatId: chat.id); + } + } + + Future _loadFiles(int accountId, int chatId) async { + if (mounted) setState(() => _filesLoading = true); + final files = await CloudStorageModule.fetchFiles(messagesModule, accountId, chatId); + if (!mounted) return; + setState(() { + _files = files.reversed.toList(); + _filesLoading = false; + }); + } + + void _prependFile(CloudFile file) { + setState(() { + _files = [file, ..._files]; + _animateNewCard = true; + }); + if (_pageController.hasClients) { + _pageController.animateToPage( + 0, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + ); + } + Future.delayed(const Duration(milliseconds: 800), () { + if (mounted) setState(() => _animateNewCard = false); + }); + } + + Future _setupEnv() async { + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + if (profile == null) { + showCustomNotification(context, 'Нет активного профиля'); + return; + } + setState(() => _isCreatingEnv = true); + final result = await CloudStorageModule.setupEnv(api); + if (!mounted) return; + if (result == null) { + setState(() => _isCreatingEnv = false); + showCustomNotification(context, 'Не удалось создать среду'); + return; + } + await CloudStorageModule.cacheEnvGroupId(profile.id, result.id); + setState(() { + _isCreatingEnv = false; + _envState = _EnvState.ready; + _envGroupId = result.id; + _accountId = profile.id; + }); + _loadFiles(profile.id, result.id); + } + + Future _pickAndUploadFile() async { + final chatId = _envGroupId; + final accountId = _accountId; + if (chatId == null || accountId == null) return; + + final result = await FilePicker.platform.pickFiles(); + if (result == null || result.files.isEmpty) return; + final picked = result.files.first; + if (picked.path == null) return; + + setState(() { _isUploading = true; _uploadProgress = 0; }); + + await UploadManager.instance.start( + chatId: chatId, + accountId: accountId, + file: File(picked.path!), + filename: picked.name, + totalSize: picked.size, + ); + } + + void _showSendByIdSheet() { + final chatId = _envGroupId; + final accountId = _accountId; + if (chatId == null || accountId == null) return; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _SendByIdSheet( + onSend: (id) async { + final ok = await messagesModule.sendFileMessage(chatId, id); + if (!ok) return false; + // Fetch just the new file — avoids full 200-msg reload + final newest = await CloudStorageModule.fetchLatestFile( + messagesModule, accountId, chatId, expectedFileId: id, + ); + if (mounted) { + if (newest != null) { + _prependFile(newest); + } else { + _loadFiles(accountId, chatId); + } + } + return true; + }, + ), + ); + } + + void _onCardTap(CloudFile file) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _FileDetailsSheet(file: file), + ); } @override @@ -50,111 +287,55 @@ class _CloudStorageScreenState extends State elevation: 0, leading: IconButton( icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: _onBack, + onPressed: () { + if (_showUploadCard) { + setState(() => _showUploadCard = false); + } else { + Navigator.pop(context); + } + }, ), title: Text( 'Облачное хранилище', style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), ), ), - body: LayoutBuilder( - builder: (context, constraints) { - final h = constraints.maxHeight; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onVerticalDragUpdate: (d) => _mode.handleDragUpdate(d, h), - onVerticalDragEnd: _mode.handleDragEnd, - child: AnimatedBuilder( - animation: _mode.anim, - builder: (context, _) { - final t = Curves.easeOutCubic.transform(_mode.anim.value); - return Stack( - fit: StackFit.expand, - children: [ - _buildHint(cs, t), - _buildUploadingCenterHint(cs, t), - _buildEmptyState(cs, t, h), - ..._buildCornerActions(cs, t), - ], - ); - }, - ), - ); - }, - ), + body: switch (_envState) { + _EnvState.loading => const Center(child: CircularProgressIndicator()), + _EnvState.notConfigured => _buildNotConfigured(cs), + _EnvState.ready => _buildReady(cs), + }, ); } - Widget _buildHint(ColorScheme cs, double t) { - return Positioned( - top: 0, - left: _hintSidePadding, - right: _hintSidePadding, - child: IgnorePointer( - child: Opacity( - opacity: t, - child: _DragDownHint(color: cs.onSurfaceVariant), - ), - ), - ); - } - - Widget _buildUploadingCenterHint(ColorScheme cs, double t) { + Widget _buildNotConfigured(ColorScheme cs) { return Center( - child: Opacity( - opacity: t, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), - child: Text( - 'Начните загрузку для прогресс-бара', - textAlign: TextAlign.center, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - ), - ), - ); - } - - Widget _buildEmptyState(ColorScheme cs, double t, double availableHeight) { - return Transform.translate( - offset: Offset(0, -t * availableHeight * _translateFactor), child: Padding( padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'Облачных файлов пока нет...', + 'Среда для облачного хранилища не настроена', textAlign: TextAlign.center, - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600), ), const SizedBox(height: 6), - Text( - 'Добавите?', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), + Text('Начнем? Это быстро.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)), const SizedBox(height: 24), FilledButton( - onPressed: _mode.open, + onPressed: _isCreatingEnv ? null : _setupEnv, style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 14, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - child: const Text( - 'Загрузить', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), ), + child: _isCreatingEnv + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary), + ) + : const Text('Начать', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), ), ], ), @@ -162,115 +343,266 @@ class _CloudStorageScreenState extends State ); } - List _buildCornerActions(ColorScheme cs, double t) { - final slide = (1 - t) * _cornerSlideAmount; - return [ - Positioned( - bottom: _cornerBottomPadding, - left: _cornerSidePadding - slide, - child: Opacity( - opacity: t, - child: _CornerAction( - icon: Symbols.upload_file, - label: 'С файла', - onTap: () {}, + Widget _buildReady(ColorScheme cs) { + return LayoutBuilder(builder: (context, constraints) { + final cardSide = constraints.maxWidth * _cardViewportFraction; + return Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _showUploadCard + ? () => setState(() => _showUploadCard = false) + : null, + child: _buildMainContent(cs), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 380), + reverseDuration: const Duration(milliseconds: 220), + transitionBuilder: (child, anim) => SlideTransition( + position: Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), + child: FadeTransition(opacity: anim, child: child), + ), + child: _showUploadCard + ? _buildUploadCard(cs, cardSide) + : const SizedBox.shrink(), + ), + ], + ); + }); + } + + Widget _buildMainContent(ColorScheme cs) { + if (_filesLoading) return const Center(child: CircularProgressIndicator()); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _files.isEmpty ? 'Облачных файлов пока нет...' : '${_files.length} ${_pluralFiles(_files.length)} в облаке', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text( + _files.isEmpty ? 'Добавите?' : 'Добавить ещё?', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _showUploadCard ? null : () => setState(() => _showUploadCard = true), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + child: const Text('Загрузить', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } + + Widget _buildUploadCard(ColorScheme cs, double cardSide) { + return Align( + alignment: Alignment.bottomCenter, + child: GestureDetector( + onTap: () {}, + child: Container( + key: const ValueKey('upload_card'), + margin: const EdgeInsets.fromLTRB(12, 0, 12, 24), + padding: const EdgeInsets.fromLTRB(16, 20, 16, 20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.4), width: 0.5), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_files.isNotEmpty) ...[ + SizedBox( + height: cardSide, + child: ScrollConfiguration( + behavior: _MouseDragScrollBehavior(), + child: PageView.builder( + controller: _pageController, + itemCount: _files.length, + itemBuilder: (_, i) { + final card = _CloudFileCard( + file: _files[i], + onTap: () => _onCardTap(_files[i]), + ); + final padded = Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: card, + ); + if (i == 0 && _animateNewCard) { + return _FadeScaleEntry( + key: ValueKey('${_files[0].messageId}_${_files[0].time}'), + child: padded, + ); + } + return padded; + }, + ), + ), + ), + // Page indicator + const SizedBox(height: 8), + Center( + child: ValueListenableBuilder( + valueListenable: _currentFilePage, + builder: (context, page, child) => Text( + '${page + 1} / ${_files.length}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + ), + ), + const SizedBox(height: 12), + ], + if (_isUploading) ...[ + LinearProgressIndicator( + value: _uploadProgress, + borderRadius: BorderRadius.circular(4), + minHeight: 5, + color: cs.primary, + backgroundColor: cs.surfaceContainerHighest, + ), + const SizedBox(height: 6), + Text( + 'Загрузка ${(_uploadProgress * 100).toStringAsFixed(0)}%', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + const SizedBox(height: 16), + ] else ...[ + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + 'Начните загрузку для прогресс-бара', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + ], + Row( + children: [ + Expanded( + child: _CardActionButton( + icon: Symbols.upload_file, + label: 'С файла', + onTap: _isUploading ? null : _pickAndUploadFile, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _CardActionButton( + icon: Symbols.tag, + label: 'По ID', + onTap: _showSendByIdSheet, + ), + ), + ], + ), + ], ), ), ), - Positioned( - bottom: _cornerBottomPadding, - right: _cornerSidePadding - slide, - child: Opacity( - opacity: t, - child: _CornerAction( - icon: Symbols.tag, - label: 'По ID', - onTap: () {}, - ), - ), + ); + } + + static String _pluralFiles(int n) { + if (n % 10 == 1 && n % 100 != 11) return 'файл'; + if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'файла'; + return 'файлов'; + } +} + +class _MouseDragScrollBehavior extends MaterialScrollBehavior { + @override + Set get dragDevices => { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }; +} + +class _FadeScaleEntry extends StatefulWidget { + final Widget child; + const _FadeScaleEntry({super.key, required this.child}); + + @override + State<_FadeScaleEntry> createState() => _FadeScaleEntryState(); +} + +class _FadeScaleEntryState extends State<_FadeScaleEntry> + with SingleTickerProviderStateMixin { + late final AnimationController _c; + late final Animation _scale; + late final Animation _opacity; + + @override + void initState() { + super.initState(); + _c = AnimationController(vsync: this, duration: const Duration(milliseconds: 550)); + _scale = CurvedAnimation(parent: _c, curve: Curves.elasticOut); + _opacity = CurvedAnimation(parent: _c, curve: const Interval(0, 0.4, curve: Curves.easeIn)); + _c.forward(); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _c, + builder: (_, child) => Opacity( + opacity: _opacity.value.clamp(0.0, 1.0), + child: Transform.scale(scale: _scale.value, child: child), ), - ]; + child: widget.child, + ); } } -class _UploadModeController { - static const _openDuration = Duration(milliseconds: 480); - static const _closeDuration = Duration(milliseconds: 320); - static const _dragRangeFactor = 0.55; - static const _flingVelocityThreshold = 280.0; - static const _snapMidpoint = 0.5; - - final AnimationController anim; - - _UploadModeController(TickerProvider vsync) - : anim = AnimationController( - vsync: vsync, - duration: _openDuration, - reverseDuration: _closeDuration, - ); - - bool get isOpen => anim.value > 0; - - void open() => anim.forward(); - void close() => anim.reverse(); - - void handleDragUpdate(DragUpdateDetails d, double availableHeight) { - if (anim.value == 0) return; - final delta = d.primaryDelta ?? 0; - final next = anim.value - delta / (availableHeight * _dragRangeFactor); - anim.value = next.clamp(0.0, 1.0); - } - - void handleDragEnd(DragEndDetails d) { - final v = d.primaryVelocity ?? 0; - if (v.abs() > _flingVelocityThreshold) { - v > 0 ? close() : open(); - return; - } - anim.value < _snapMidpoint ? close() : open(); - } - - void dispose() => anim.dispose(); -} - -class _CornerAction extends StatelessWidget { +class _CardActionButton extends StatelessWidget { final IconData icon; final String label; - final VoidCallback onTap; + final VoidCallback? onTap; - const _CornerAction({ - required this.icon, - required this.label, - required this.onTap, - }); + const _CardActionButton({required this.icon, required this.label, required this.onTap}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final active = onTap != null; return Material( color: Colors.transparent, child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(14), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: cs.outlineVariant.withValues(alpha: 0.5), - width: 0.5, - ), + color: active ? cs.primaryContainer : cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), ), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, color: cs.onSurface, size: 20), - const SizedBox(width: 8), + Icon(icon, size: 18, color: active ? cs.onPrimaryContainer : cs.onSurfaceVariant), + const SizedBox(width: 6), Text( label, style: TextStyle( - color: cs.onSurface, + color: active ? cs.onPrimaryContainer : cs.onSurfaceVariant, fontSize: 14, fontWeight: FontWeight.w600, ), @@ -283,79 +615,363 @@ class _CornerAction extends StatelessWidget { } } -class _DragDownHint extends StatefulWidget { - final Color color; +class _CloudFileCard extends StatelessWidget { + final CloudFile file; + final VoidCallback onTap; - const _DragDownHint({required this.color}); + const _CloudFileCard({required this.file, required this.onTap}); - @override - State<_DragDownHint> createState() => _DragDownHintState(); -} - -class _DragDownHintState extends State<_DragDownHint> - with SingleTickerProviderStateMixin { - static const _cycle = Duration(milliseconds: 2800); - static const _activeFraction = 0.4; - static const _height = 3.0; - static const _slotHeight = 28.0; - static const _startY = -6.0; - static const _travel = 16.0; - static const _peakOpacity = 0.32; - - late final AnimationController _c; - - @override - void initState() { - super.initState(); - _c = AnimationController(vsync: this, duration: _cycle)..repeat(); + static IconData _icon(String name) { + final ext = name.contains('.') ? name.split('.').last.toLowerCase() : ''; + return switch (ext) { + 'pdf' => Symbols.picture_as_pdf, + 'jpg' || 'jpeg' || 'png' || 'gif' || 'webp' || 'bmp' => Symbols.image, + 'mp4' || 'mov' || 'avi' || 'mkv' => Symbols.video_file, + 'mp3' || 'wav' || 'ogg' || 'flac' => Symbols.audio_file, + 'zip' || 'rar' || '7z' || 'tar' || 'gz' => Symbols.folder_zip, + 'doc' || 'docx' => Symbols.description, + 'xls' || 'xlsx' => Symbols.table_chart, + 'ppt' || 'pptx' => Symbols.slideshow, + 'txt' => Symbols.text_snippet, + _ => Symbols.insert_drive_file, + }; } - @override - void dispose() { - _c.dispose(); - super.dispose(); + static String _formatTime(int millis) { + final d = DateTime.fromMillisecondsSinceEpoch(millis); + final now = DateTime.now(); + if (d.year == now.year && d.month == now.month && d.day == now.day) { + return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + } + return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}'; } @override Widget build(BuildContext context) { - return SizedBox( - height: _slotHeight, - child: AnimatedBuilder( - animation: _c, - builder: (context, _) { - final ghost = _ghostFor(_c.value); - if (ghost.opacity <= 0) return const SizedBox.shrink(); - return Stack( + final cs = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: AspectRatio( + aspectRatio: 1.0, + child: Container( + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Positioned( - left: 0, - right: 0, - top: ghost.dy, - height: _height, - child: Opacity( - opacity: ghost.opacity, - child: DecoratedBox( - decoration: BoxDecoration( - color: widget.color, - borderRadius: BorderRadius.circular(2), + Expanded( + child: Center( + child: Icon(_icon(file.name), color: cs.primary, size: 34), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 10), + child: Row( + children: [ + Expanded( + child: Text( + file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), ), - ), + const SizedBox(width: 4), + Text( + _formatTime(file.time), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), + ), + ], ), ), ], - ); - }, + ), + ), ), ); } +} - ({double dy, double opacity}) _ghostFor(double phase) { - if (phase > _activeFraction) return (dy: 0, opacity: 0); - final local = phase / _activeFraction; - final eased = Curves.easeOutCubic.transform(local); - return ( - dy: _startY + eased * _travel, - opacity: (1 - local) * _peakOpacity, +class _FileDetailsSheet extends StatefulWidget { + final CloudFile file; + const _FileDetailsSheet({required this.file}); + + @override + State<_FileDetailsSheet> createState() => _FileDetailsSheetState(); +} + +class _FileDetailsSheetState extends State<_FileDetailsSheet> { + ({String url, int expires})? _link; + bool _loading = false; + Timer? _expiryTimer; + + @override + void initState() { + super.initState(); + final f = widget.file; + if (f.fileId != null) { + _link = CloudStorageModule.getCachedLink(f.accountId, f.fileId!); + } + // Refresh the expiry countdown every minute + _expiryTimer = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _expiryTimer?.cancel(); + super.dispose(); + } + + Future _generateLink() async { + final f = widget.file; + if (f.fileId == null) return; + setState(() => _loading = true); + final result = await CloudStorageModule.fetchFileUrl( + api, + accountId: f.accountId, + fileId: f.fileId!, + chatId: f.chatId, + messageId: f.messageId, + ); + if (mounted) setState(() { _link = result; _loading = false; }); + } + + static String _formatSize(int? bytes) { + if (bytes == null) return '—'; + if (bytes < 1024) return '$bytes Б'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ'; + } + + static String _formatExpiry(int expiresMs) { + final remaining = DateTime.fromMillisecondsSinceEpoch(expiresMs).difference(DateTime.now()); + if (remaining.isNegative) return 'истекла'; + final h = remaining.inHours; + final m = remaining.inMinutes % 60; + if (h >= 24) return 'через ${remaining.inDays} д'; + if (h > 0) return 'через $h ч $m мин'; + return 'через $m мин'; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final f = widget.file; + final isExpired = _link == null || + _link!.expires <= DateTime.now().millisecondsSinceEpoch; + + return Container( + decoration: BoxDecoration( + color: cs.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.fromLTRB( + 24, 16, 24, + MediaQuery.of(context).viewInsets.bottom + 32, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + Text( + f.name, + style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + _InfoRow(label: 'ID файла', value: f.fileId?.toString() ?? '—'), + const SizedBox(height: 6), + _InfoRow(label: 'Размер', value: _formatSize(f.size)), + const SizedBox(height: 20), + Container(height: 0.5, color: cs.outlineVariant), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: isExpired + ? Text( + 'Ссылки пока нет. Создайте.', + style: TextStyle(color: cs.error, fontSize: 13), + ) + : Text( + 'Ссылка истечет ${_formatExpiry(_link!.expires)}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + const SizedBox(width: 8), + _loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: cs.primary), + ) + : IconButton( + icon: Icon( + isExpired ? Symbols.add_link : Symbols.content_copy, + color: isExpired ? cs.error : cs.onSurfaceVariant, + size: 20, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: isExpired + ? _generateLink + : () { + Clipboard.setData(ClipboardData(text: _link!.url)); + showCustomNotification(context, 'Ссылка скопирована'); + }, + ), + ], + ), + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + const _InfoRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Row( + children: [ + Text('$label: ', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Expanded( + child: Text( + value, + style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +class _SendByIdSheet extends StatefulWidget { + final Future Function(int fileId) onSend; + const _SendByIdSheet({required this.onSend}); + + @override + State<_SendByIdSheet> createState() => _SendByIdSheetState(); +} + +class _SendByIdSheetState extends State<_SendByIdSheet> { + final _controller = TextEditingController(); + bool _sending = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _submit() async { + final id = int.tryParse(_controller.text.trim()); + if (id == null) { + showCustomNotification(context, 'Неверный ID'); + return; + } + setState(() => _sending = true); + final ok = await widget.onSend(id); + if (!mounted) return; + if (ok) { + Navigator.pop(context); + } else { + setState(() => _sending = false); + showCustomNotification(context, 'Ошибка отправки'); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + color: cs.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.fromLTRB( + 24, 16, 24, + MediaQuery.of(context).viewInsets.bottom + 32, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + Text( + 'Отправить по ID', + style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + TextField( + controller: _controller, + autofocus: true, + keyboardType: TextInputType.number, + style: TextStyle(color: cs.onSurface, fontSize: 15), + onSubmitted: (_) => _sending ? null : _submit(), + decoration: InputDecoration( + hintText: 'fileId', + hintStyle: TextStyle(color: cs.onSurfaceVariant), + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _sending ? null : _submit, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: _sending + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary), + ) + : const Text('Отправить', style: TextStyle(fontWeight: FontWeight.w600)), + ), + ], + ), ); } } From 65dd15091a825b626fa3436a63adadd19b3c9c05 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 27 May 2026 22:22:30 +0700 Subject: [PATCH 160/323] =?UTF-8?q?=D0=BA=D0=B0=D0=BA=D0=B8=D0=B5=20=D1=82?= =?UTF-8?q?=D0=BE=20=D0=B2=D1=8B=D0=BA=D1=80=D1=83=D1=82=D0=B0=D1=81=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=B8=D1=81=D1=8C=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 18 +- lib/core/protocol/packet.dart | 3 +- lib/core/transport/dispatcher.dart | 5 +- lib/frontend/screens/chats/chat_screen.dart | 30 + .../screens/profile/cloud_storage_screen.dart | 575 +++++++++++------- 5 files changed, 395 insertions(+), 236 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e64996e..6fb91eb 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../models/attachment.dart'; import 'chats.dart' show ChatsModule; @@ -462,8 +463,9 @@ class MessagesModule { int fileId, { String? token, bool notify = true, - int maxAttempts = 5, + int maxAttempts = 20, Duration retryDelay = const Duration(seconds: 1), + Duration initialDelay = const Duration(seconds: 3), }) async { final payload = { 'chatId': chatId, @@ -482,14 +484,18 @@ class MessagesModule { 'notify': notify, }; + await Future.delayed(initialDelay); + for (var attempt = 0; attempt < maxAttempts; attempt++) { - final response = await _api.sendRequest(Opcode.msgSend, payload); - if (response.isOk) return true; - final err = response.payload is Map ? response.payload['error'] : null; - if (err != 'attachment.not.ready' || attempt == maxAttempts - 1) { + try { + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (response.isOk) return true; return false; + } on PacketError catch (e) { + if (e.errorKey != 'attachment.not.ready') rethrow; + if (attempt == maxAttempts - 1) return false; + await Future.delayed(retryDelay); } - await Future.delayed(retryDelay); } return false; } diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 592ddd2..75af3a2 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -55,7 +55,8 @@ class Packet { class PacketError implements Exception { final String message; - const PacketError(this.message); + final String? errorKey; + const PacketError(this.message, {this.errorKey}); @override String toString() => message; } diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index 89c6237..ff776c8 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -70,11 +70,14 @@ class PacketDispatcher { if (packet.isError) { final message = messageFromErrorPayload(packet.payload); + final errorKey = packet.payload is Map + ? packet.payload['error']?.toString() + : null; if (packet.payload is Map && packet.payload['message'] == 'FAIL_LOGIN_TOKEN') { completer.completeError(SessionExpiredException(message)); } else { - completer.completeError(PacketError(message)); + completer.completeError(PacketError(message, errorKey: errorKey)); } } else { completer.complete(packet); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 4b9fa90..bfdefd3 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/file_uploader.dart'; +import 'package:komet/backend/modules/upload_notification_service.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -1347,6 +1348,15 @@ class _ChatScreenState extends State size: file.size, )); + UploadNotificationService.start(file.name); + + var notifLastSent = 0; + var notifLastMs = DateTime.now().millisecondsSinceEpoch; + var notifSpeedBps = 0; + var notifLastPercent = -1; + + void stopNotif() => UploadNotificationService.stop(); + _uploadSub?.cancel(); _uploadSub = fileUploader .upload( @@ -1361,7 +1371,24 @@ class _ChatScreenState extends State switch (event) { case UploadProgress(:final sent, :final total): _uploadStatus.value = _UploadStatus(active: true, sent: sent, total: total); + final nowMs = DateTime.now().millisecondsSinceEpoch; + final elapsed = nowMs - notifLastMs; + if (elapsed >= 500) { + notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed).round(); + notifLastSent = sent; + notifLastMs = nowMs; + } + final percent = total > 0 ? (sent * 100 ~/ total) : 0; + if (percent != notifLastPercent) { + notifLastPercent = percent; + UploadNotificationService.update( + filename: file.name, + progressPercent: percent, + speedBps: notifSpeedBps, + ); + } case UploadDone(:final fileId, :final token, :final url): + stopNotif(); FileHistoryCache.add(FileHistoryEntry( fileId: fileId, url: url, @@ -1381,12 +1408,14 @@ class _ChatScreenState extends State ), ); case UploadError(:final message): + stopNotif(); showCustomNotification(context, 'Ошибка: $message'); _updateFileMessageStatus(tempId, 'error'); } }, onDone: () { if (!mounted) return; + stopNotif(); final inFlight = _messages.firstWhere( (m) => m.id == tempId, orElse: () => CachedMessage( @@ -1401,6 +1430,7 @@ class _ChatScreenState extends State }, onError: (Object e) { if (!mounted) return; + stopNotif(); showCustomNotification(context, 'Ошибка: $e'); _updateFileMessageStatus(tempId, 'error'); _uploadStatus.value = const _UploadStatus(); diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index b776637..1f353b0 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -23,10 +23,17 @@ class CloudStorageScreen extends StatefulWidget { State createState() => _CloudStorageScreenState(); } -class _CloudStorageScreenState extends State { +class _CloudStorageScreenState extends State + with SingleTickerProviderStateMixin { + static const _translateFactor = 0.7; static const _horizontalPadding = 32.0; - static const _cardViewportFraction = 0.38; + static const _hintSidePadding = 35.0; + static const _cornerSidePadding = 16.0; + static const _cornerBottomPadding = 24.0; + static const _cornerSlideAmount = 28.0; + static const _cardViewportFraction = 0.42; + late final _UploadModeController _mode; late final PageController _pageController; final _currentFilePage = ValueNotifier(0); @@ -35,28 +42,31 @@ class _CloudStorageScreenState extends State { int? _envGroupId; int? _accountId; List _files = []; - bool _filesLoading = false; bool _isUploading = false; double _uploadProgress = 0; bool _animateNewCard = false; - bool _showUploadCard = false; @override void initState() { super.initState(); + _mode = _UploadModeController(this); _pageController = PageController(viewportFraction: _cardViewportFraction); _pageController.addListener(_onPageScroll); _checkEnv(); _bindUploadManager(); } + void _onPageScroll() { + _currentFilePage.value = _pageController.page?.round() ?? 0; + } + void _bindUploadManager() { final mgr = UploadManager.instance; if (mgr.isActive) { - // Upload was already running (e.g. user left and came back) - setState(() { _isUploading = true; _showUploadCard = true; }); + setState(() => _isUploading = true); + _mode.open(); } - mgr.onProgress = (progress, speedBps) { + mgr.onProgress = (progress, _) { if (!mounted) return; setState(() { _isUploading = true; _uploadProgress = progress; }); }; @@ -72,17 +82,13 @@ class _CloudStorageScreenState extends State { }; } - void _onPageScroll() { - _currentFilePage.value = _pageController.page?.round() ?? 0; - } - @override void dispose() { - // Unregister UI callbacks — upload continues in background via UploadManager final mgr = UploadManager.instance; mgr.onProgress = null; mgr.onDone = null; mgr.onError = null; + _mode.dispose(); _pageController.dispose(); _currentFilePage.dispose(); super.dispose(); @@ -95,7 +101,6 @@ class _CloudStorageScreenState extends State { return; } - // Fast path: validate cached env group ID with a single DB row query final cachedId = await CloudStorageModule.getCachedEnvGroupId(profile.id); if (cachedId != null) { final rows = await ChatsModule.getChat(profile.id, cachedId); @@ -107,31 +112,27 @@ class _CloudStorageScreenState extends State { _accountId = profile.id; }); _loadFiles(profile.id, cachedId); - _handleOrphansBackground(profile.id, cachedId); + _handleOrphansBackground(profile.id); return; } await CloudStorageModule.clearEnvGroupCache(profile.id); } - // Full scan final chats = await ChatsModule.getChats(profile.id); CachedChat? envGroup = CloudStorageModule.findEnvGroup(chats); final orphans = CloudStorageModule.findOrphanGroups(chats); if (envGroup == null && orphans.isNotEmpty) { - // Repair first orphan into a valid env group final repaired = await CloudStorageModule.repairOrphan(api, orphans.first); if (repaired != null) { envGroup = repaired; await CloudStorageModule.cacheEnvGroupId(profile.id, repaired.id); } - // If there were multiple orphans, clean up the rest for (final orphan in orphans.skip(1)) { _deleteOrLeave(profile.id, orphan); } } else if (envGroup != null) { await CloudStorageModule.cacheEnvGroupId(profile.id, envGroup.id); - // Env already exists — delete or leave any orphan groups for (final orphan in orphans) { _deleteOrLeave(profile.id, orphan); } @@ -146,11 +147,9 @@ class _CloudStorageScreenState extends State { if (envGroup != null) _loadFiles(profile.id, envGroup.id); } - // Runs in background after the fast-cache path — loads all chats to find and remove orphans - void _handleOrphansBackground(int accountId, int envGroupId) async { + void _handleOrphansBackground(int accountId) async { final chats = await ChatsModule.getChats(accountId); - final orphans = CloudStorageModule.findOrphanGroups(chats); - for (final orphan in orphans) { + for (final orphan in CloudStorageModule.findOrphanGroups(chats)) { _deleteOrLeave(accountId, orphan); } } @@ -165,13 +164,9 @@ class _CloudStorageScreenState extends State { } Future _loadFiles(int accountId, int chatId) async { - if (mounted) setState(() => _filesLoading = true); final files = await CloudStorageModule.fetchFiles(messagesModule, accountId, chatId); if (!mounted) return; - setState(() { - _files = files.reversed.toList(); - _filesLoading = false; - }); + setState(() => _files = files.reversed.toList()); } void _prependFile(CloudFile file) { @@ -180,11 +175,8 @@ class _CloudStorageScreenState extends State { _animateNewCard = true; }); if (_pageController.hasClients) { - _pageController.animateToPage( - 0, - duration: const Duration(milliseconds: 350), - curve: Curves.easeOut, - ); + _pageController.animateToPage(0, + duration: const Duration(milliseconds: 350), curve: Curves.easeOut); } Future.delayed(const Duration(milliseconds: 800), () { if (mounted) setState(() => _animateNewCard = false); @@ -250,7 +242,6 @@ class _CloudStorageScreenState extends State { onSend: (id) async { final ok = await messagesModule.sendFileMessage(chatId, id); if (!ok) return false; - // Fetch just the new file — avoids full 200-msg reload final newest = await CloudStorageModule.fetchLatestFile( messagesModule, accountId, chatId, expectedFileId: id, ); @@ -276,6 +267,14 @@ class _CloudStorageScreenState extends State { ); } + void _onBack() { + if (_mode.isOpen) { + _mode.close(); + } else { + Navigator.pop(context); + } + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -287,13 +286,7 @@ class _CloudStorageScreenState extends State { elevation: 0, leading: IconButton( icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: () { - if (_showUploadCard) { - setState(() => _showUploadCard = false); - } else { - Navigator.pop(context); - } - }, + onPressed: _onBack, ), title: Text( 'Облачное хранилище', @@ -331,8 +324,7 @@ class _CloudStorageScreenState extends State { ), child: _isCreatingEnv ? SizedBox( - width: 18, - height: 18, + width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary), ) : const Text('Начать', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), @@ -344,90 +336,61 @@ class _CloudStorageScreenState extends State { } Widget _buildReady(ColorScheme cs) { - return LayoutBuilder(builder: (context, constraints) { - final cardSide = constraints.maxWidth * _cardViewportFraction; - return Stack( - fit: StackFit.expand, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _showUploadCard - ? () => setState(() => _showUploadCard = false) - : null, - child: _buildMainContent(cs), + return LayoutBuilder( + builder: (context, constraints) { + final h = constraints.maxHeight; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (d) => _mode.handleDragUpdate(d, h), + onVerticalDragEnd: _mode.handleDragEnd, + child: AnimatedBuilder( + animation: _mode.anim, + builder: (context, _) { + final t = Curves.easeOutCubic.transform(_mode.anim.value); + return Stack( + fit: StackFit.expand, + children: [ + _buildHint(cs, t), + _buildUploadingCenterHint(cs, t, constraints.maxWidth), + _buildEmptyState(cs, t, h), + ..._buildCornerActions(cs, t), + ], + ); + }, ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 380), - reverseDuration: const Duration(milliseconds: 220), - transitionBuilder: (child, anim) => SlideTransition( - position: Tween( - begin: const Offset(0, 0.15), - end: Offset.zero, - ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), - child: FadeTransition(opacity: anim, child: child), - ), - child: _showUploadCard - ? _buildUploadCard(cs, cardSide) - : const SizedBox.shrink(), - ), - ], - ); - }); + ); + }, + ); } - Widget _buildMainContent(ColorScheme cs) { - if (_filesLoading) return const Center(child: CircularProgressIndicator()); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _files.isEmpty ? 'Облачных файлов пока нет...' : '${_files.length} ${_pluralFiles(_files.length)} в облаке', - textAlign: TextAlign.center, - style: TextStyle(color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 6), - Text( - _files.isEmpty ? 'Добавите?' : 'Добавить ещё?', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: _showUploadCard ? null : () => setState(() => _showUploadCard = true), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - ), - child: const Text('Загрузить', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), - ), - ], + Widget _buildHint(ColorScheme cs, double t) { + return Positioned( + top: 0, + left: _hintSidePadding, + right: _hintSidePadding, + child: IgnorePointer( + child: Opacity( + opacity: t, + child: _DragDownHint(color: cs.onSurfaceVariant), + ), ), ); } - Widget _buildUploadCard(ColorScheme cs, double cardSide) { - return Align( - alignment: Alignment.bottomCenter, - child: GestureDetector( - onTap: () {}, - child: Container( - key: const ValueKey('upload_card'), - margin: const EdgeInsets.fromLTRB(12, 0, 12, 24), - padding: const EdgeInsets.fromLTRB(16, 20, 16, 20), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.4), width: 0.5), - ), + Widget _buildUploadingCenterHint(ColorScheme cs, double t, double availableWidth) { + final cardSide = availableWidth * _cardViewportFraction; + return Center( + child: Opacity( + opacity: t, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_files.isNotEmpty) ...[ SizedBox( height: cardSide, + width: availableWidth, child: ScrollConfiguration( behavior: _MouseDragScrollBehavior(), child: PageView.builder( @@ -439,7 +402,7 @@ class _CloudStorageScreenState extends State { onTap: () => _onCardTap(_files[i]), ); final padded = Padding( - padding: const EdgeInsets.symmetric(horizontal: 5), + padding: const EdgeInsets.symmetric(horizontal: 6), child: card, ); if (i == 0 && _animateNewCard) { @@ -453,18 +416,15 @@ class _CloudStorageScreenState extends State { ), ), ), - // Page indicator const SizedBox(height: 8), - Center( - child: ValueListenableBuilder( - valueListenable: _currentFilePage, - builder: (context, page, child) => Text( - '${page + 1} / ${_files.length}', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), - ), + ValueListenableBuilder( + valueListenable: _currentFilePage, + builder: (context, page, child) => Text( + '${page + 1} / ${_files.length}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), ), ), - const SizedBox(height: 12), + const SizedBox(height: 16), ], if (_isUploading) ...[ LinearProgressIndicator( @@ -474,40 +434,18 @@ class _CloudStorageScreenState extends State { color: cs.primary, backgroundColor: cs.surfaceContainerHighest, ), - const SizedBox(height: 6), + const SizedBox(height: 8), Text( 'Загрузка ${(_uploadProgress * 100).toStringAsFixed(0)}%', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), - const SizedBox(height: 16), - ] else ...[ - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Text( - 'Начните загрузку для прогресс-бара', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), + ] else if (_files.isEmpty) ...[ + Text( + 'Начните загрузку для прогресс-бара', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), ], - Row( - children: [ - Expanded( - child: _CardActionButton( - icon: Symbols.upload_file, - label: 'С файла', - onTap: _isUploading ? null : _pickAndUploadFile, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _CardActionButton( - icon: Symbols.tag, - label: 'По ID', - onTap: _showSendByIdSheet, - ), - ), - ], - ), ], ), ), @@ -515,10 +453,248 @@ class _CloudStorageScreenState extends State { ); } - static String _pluralFiles(int n) { - if (n % 10 == 1 && n % 100 != 11) return 'файл'; - if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'файла'; - return 'файлов'; + Widget _buildEmptyState(ColorScheme cs, double t, double availableHeight) { + return Transform.translate( + offset: Offset(0, -t * availableHeight * _translateFactor), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Облачных файлов пока нет...', + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + 'Добавите?', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _mode.isOpen ? null : _mode.open, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: const Text( + 'Загрузить', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ); + } + + List _buildCornerActions(ColorScheme cs, double t) { + final slide = (1 - t) * _cornerSlideAmount; + return [ + Positioned( + bottom: _cornerBottomPadding, + left: _cornerSidePadding - slide, + child: Opacity( + opacity: t, + child: _CornerAction( + icon: Symbols.upload_file, + label: 'С файла', + onTap: _pickAndUploadFile, + ), + ), + ), + Positioned( + bottom: _cornerBottomPadding, + right: _cornerSidePadding - slide, + child: Opacity( + opacity: t, + child: _CornerAction( + icon: Symbols.tag, + label: 'По ID', + onTap: _showSendByIdSheet, + ), + ), + ), + ]; + } +} + +class _UploadModeController { + static const _openDuration = Duration(milliseconds: 480); + static const _closeDuration = Duration(milliseconds: 320); + static const _dragRangeFactor = 0.55; + static const _flingVelocityThreshold = 280.0; + static const _snapMidpoint = 0.5; + + final AnimationController anim; + + _UploadModeController(TickerProvider vsync) + : anim = AnimationController( + vsync: vsync, + duration: _openDuration, + reverseDuration: _closeDuration, + ); + + bool get isOpen => anim.value > 0; + + void open() => anim.forward(); + void close() => anim.reverse(); + + void handleDragUpdate(DragUpdateDetails d, double availableHeight) { + if (anim.value == 0) return; + final delta = d.primaryDelta ?? 0; + final next = anim.value - delta / (availableHeight * _dragRangeFactor); + anim.value = next.clamp(0.0, 1.0); + } + + void handleDragEnd(DragEndDetails d) { + final v = d.primaryVelocity ?? 0; + if (v.abs() > _flingVelocityThreshold) { + v > 0 ? close() : open(); + return; + } + anim.value < _snapMidpoint ? close() : open(); + } + + void dispose() => anim.dispose(); +} + +class _CornerAction extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _CornerAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: cs.onSurface, size: 20), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _DragDownHint extends StatefulWidget { + final Color color; + + const _DragDownHint({required this.color}); + + @override + State<_DragDownHint> createState() => _DragDownHintState(); +} + +class _DragDownHintState extends State<_DragDownHint> + with SingleTickerProviderStateMixin { + static const _cycle = Duration(milliseconds: 2800); + static const _activeFraction = 0.4; + static const _height = 3.0; + static const _slotHeight = 28.0; + static const _startY = -6.0; + static const _travel = 16.0; + static const _peakOpacity = 0.32; + + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController(vsync: this, duration: _cycle)..repeat(); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _slotHeight, + child: AnimatedBuilder( + animation: _c, + builder: (context, _) { + final ghost = _ghostFor(_c.value); + if (ghost.opacity <= 0) return const SizedBox.shrink(); + return Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: ghost.dy, + height: _height, + child: Opacity( + opacity: ghost.opacity, + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.color, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + + ({double dy, double opacity}) _ghostFor(double phase) { + if (phase > _activeFraction) return (dy: 0, opacity: 0); + final local = phase / _activeFraction; + final eased = Curves.easeOutCubic.transform(local); + return ( + dy: _startY + eased * _travel, + opacity: (1 - local) * _peakOpacity, + ); } } @@ -563,7 +739,7 @@ class _FadeScaleEntryState extends State<_FadeScaleEntry> Widget build(BuildContext context) { return AnimatedBuilder( animation: _c, - builder: (_, child) => Opacity( + builder: (context, child) => Opacity( opacity: _opacity.value.clamp(0.0, 1.0), child: Transform.scale(scale: _scale.value, child: child), ), @@ -572,49 +748,6 @@ class _FadeScaleEntryState extends State<_FadeScaleEntry> } } -class _CardActionButton extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback? onTap; - - const _CardActionButton({required this.icon, required this.label, required this.onTap}); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final active = onTap != null; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: active ? cs.primaryContainer : cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(14), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 18, color: active ? cs.onPrimaryContainer : cs.onSurfaceVariant), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - color: active ? cs.onPrimaryContainer : cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ); - } -} - class _CloudFileCard extends StatelessWidget { final CloudFile file; final VoidCallback onTap; @@ -655,7 +788,7 @@ class _CloudFileCard extends StatelessWidget { aspectRatio: 1.0, child: Container( decoration: BoxDecoration( - color: cs.surface, + color: cs.surfaceContainerLow, borderRadius: BorderRadius.circular(16), border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5), ), @@ -719,7 +852,6 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { if (f.fileId != null) { _link = CloudStorageModule.getCachedLink(f.accountId, f.fileId!); } - // Refresh the expiry countdown every minute _expiryTimer = Timer.periodic(const Duration(minutes: 1), (_) { if (mounted) setState(() {}); }); @@ -784,8 +916,7 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { children: [ Center( child: Container( - width: 36, - height: 4, + width: 36, height: 4, decoration: BoxDecoration( color: cs.outlineVariant, borderRadius: BorderRadius.circular(2), @@ -793,10 +924,8 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { ), ), const SizedBox(height: 20), - Text( - f.name, - style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700), - ), + Text(f.name, + style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)), const SizedBox(height: 12), _InfoRow(label: 'ID файла', value: f.fileId?.toString() ?? '—'), const SizedBox(height: 6), @@ -808,20 +937,15 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { children: [ Expanded( child: isExpired - ? Text( - 'Ссылки пока нет. Создайте.', - style: TextStyle(color: cs.error, fontSize: 13), - ) - : Text( - 'Ссылка истечет ${_formatExpiry(_link!.expires)}', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), + ? Text('Ссылки пока нет. Создайте.', + style: TextStyle(color: cs.error, fontSize: 13)) + : Text('Ссылка истечет ${_formatExpiry(_link!.expires)}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), ), const SizedBox(width: 8), _loading ? SizedBox( - width: 20, - height: 20, + width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: cs.primary), ) : IconButton( @@ -923,19 +1047,15 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { children: [ Center( child: Container( - width: 36, - height: 4, + width: 36, height: 4, decoration: BoxDecoration( - color: cs.outlineVariant, - borderRadius: BorderRadius.circular(2), + color: cs.outlineVariant, borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 20), - Text( - 'Отправить по ID', - style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700), - ), + Text('Отправить по ID', + style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700)), const SizedBox(height: 12), TextField( controller: _controller, @@ -964,8 +1084,7 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { ), child: _sending ? SizedBox( - width: 18, - height: 18, + width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary), ) : const Text('Отправить', style: TextStyle(fontWeight: FontWeight.w600)), From a1130ed4889673f48acccb52c681653921b3dea5 Mon Sep 17 00:00:00 2001 From: klockky Date: Wed, 27 May 2026 16:16:50 +0000 Subject: [PATCH 161/323] =?UTF-8?q?ci:=20=D0=B2=D0=BE=D1=80=D0=BA=D1=84?= =?UTF-8?q?=D0=BB=D0=BE=D1=83=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20main=20=E2=80=94=20=D1=81=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=B2=D1=81=D0=B5=D1=85=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=84=D0=BE=D1=80=D0=BC=20=D0=B8=20=D0=BF=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=20Git?= =?UTF-8?q?Hub=20Releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-main.yml | 293 +++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 .github/workflows/release-main.yml diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml new file mode 100644 index 0000000..c1d8c51 --- /dev/null +++ b/.github/workflows/release-main.yml @@ -0,0 +1,293 @@ +name: Release (main) + +on: + workflow_dispatch: + push: + branches: + - main + +permissions: + contents: write + +env: + FLUTTER_VERSION: '3.41.5' + +jobs: + android: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + flavor: [komet, oneme] + steps: + - uses: actions/checkout@v3 + + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + + - name: Get dependencies + run: flutter pub get + + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties + + - name: Setup release signing + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + run: | + if [ -z "$KEYSTORE_BASE64" ]; then + echo "KEYSTORE_BASE64 secret not set — release will be debug-signed." + exit 0 + fi + echo "$KEYSTORE_BASE64" | base64 -d > android/app/komet-release.jks + { + echo "storePassword=$KEYSTORE_PASSWORD" + echo "keyPassword=$KEY_PASSWORD" + echo "keyAlias=$KEY_ALIAS" + echo "storeFile=komet-release.jks" + } > android/key.properties + echo "Release signing configured." + + - name: Build APKs and App Bundle (${{ matrix.flavor }}) + run: | + flutter build apk --release --flavor ${{ matrix.flavor }} + flutter build apk --release --split-per-abi --flavor ${{ matrix.flavor }} + flutter build appbundle --release --flavor ${{ matrix.flavor }} + + - name: Collect Android artifacts (${{ matrix.flavor }}) + run: | + F=${{ matrix.flavor }} + mkdir -p dist + cp build/app/outputs/flutter-apk/app-$F-release.apk dist/Komet-android-$F-universal.apk + cp build/app/outputs/flutter-apk/app-arm64-v8a-$F-release.apk dist/Komet-android-$F-arm64-v8a.apk + cp build/app/outputs/flutter-apk/app-armeabi-v7a-$F-release.apk dist/Komet-android-$F-armeabi-v7a.apk + cp build/app/outputs/flutter-apk/app-x86_64-$F-release.apk dist/Komet-android-$F-x86_64.apk + cp build/app/outputs/bundle/${F}Release/app-$F-release.aab dist/Komet-android-$F.aab + + - name: Upload Android artifacts (${{ matrix.flavor }}) + uses: actions/upload-artifact@v4 + with: + name: android-${{ matrix.flavor }} + path: dist/* + if-no-files-found: error + retention-days: 7 + + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + - name: Get dependencies + run: flutter pub get + - name: Build Windows + run: flutter build windows --release + - name: Package Windows + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath dist/Komet-windows-x64.zip + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: windows + path: dist/* + if-no-files-found: error + retention-days: 7 + + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + - name: Install Linux desktop dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev + - name: Get dependencies + run: flutter pub get + - name: Build Linux + run: flutter build linux --release + - name: Package Linux + run: | + mkdir -p dist + tar -C build/linux/x64/release/bundle -czf dist/Komet-linux-x64.tar.gz . + - name: Upload Linux artifact + uses: actions/upload-artifact@v4 + with: + name: linux + path: dist/* + if-no-files-found: error + retention-days: 7 + + macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + - name: Get dependencies + run: flutter pub get + - name: Build macOS + run: flutter build macos --release + - name: Package macOS + run: | + mkdir -p dist + APP=$(find build/macos/Build/Products/Release -maxdepth 1 -name "*.app" | head -1) + ditto -c -k --keepParent "$APP" dist/Komet-macos.zip + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: macos + path: dist/* + if-no-files-found: error + retention-days: 7 + + ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + - name: Get dependencies + run: flutter pub get + - name: Build iOS (no codesign) + run: flutter build ios --release --no-codesign + - name: Install ldid + run: brew install ldid + - name: Pseudo-sign with ldid + run: | + set -euo pipefail + APP="build/ios/iphoneos/Runner.app" + ENT="$(mktemp -t ent).plist" + cat > "$ENT" <<'PLIST' + + + + + platform-application + get-task-allow + com.apple.private.security.no-container + + + PLIST + if [ -d "$APP/Frameworks" ]; then + find "$APP/Frameworks" -type f -name "*.dylib" -print0 | xargs -0 -I{} ldid -S "{}" + find "$APP/Frameworks" -type d -name "*.framework" | while read -r fw; do + bin="$fw/$(basename "$fw" .framework)" + [ -f "$bin" ] && ldid -S "$bin" + done + fi + if [ -d "$APP/PlugIns" ]; then + find "$APP/PlugIns" -type d -name "*.appex" | while read -r ext; do + bin="$ext/$(basename "$ext" .appex)" + [ -f "$bin" ] && ldid -S"$ENT" "$bin" + done + fi + ldid -S"$ENT" "$APP/Runner" + - name: Package IPA + run: | + mkdir -p dist build/ios/Payload + cp -R build/ios/iphoneos/Runner.app build/ios/Payload/ + (cd build/ios && zip -qr ../../dist/Komet-ios-pseudosigned.ipa Payload) + - name: Upload iOS artifact + uses: actions/upload-artifact@v4 + with: + name: ios + path: dist/* + if-no-files-found: error + retention-days: 7 + + release: + needs: [android, windows, linux, macos, ios] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Compute version + id: ver + run: | + PUBSPEC_VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1) + echo "tag=v${PUBSPEC_VERSION}+${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + echo "name=Komet ${PUBSPEC_VERSION} (сборка ${GITHUB_RUN_NUMBER})" >> "$GITHUB_OUTPUT" + + - name: List artifacts + run: ls -lhR dist + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.ver.outputs.tag }} + name: ${{ steps.ver.outputs.name }} + target_commitish: ${{ github.sha }} + prerelease: false + generate_release_notes: true + fail_on_unmatched_files: true + body: | + Автоматическая сборка из ветки `${{ github.ref_name }}` @ `${{ github.sha }}`. + + ## Какой файл качать + + ### Android — версия Komet (без push-уведомлений) + - **`Komet-android-komet-arm64-v8a.apk`** — рекомендуется для большинства современных телефонов (64-битный ARM). + - `Komet-android-komet-universal.apk` — универсальный APK, работает везде, но весит больше. + - `Komet-android-komet-armeabi-v7a.apk` — для старых 32-битных устройств. + - `Komet-android-komet-x86_64.apk` — для эмуляторов и редких x86-устройств. + - `Komet-android-komet.aab` — App Bundle для публикации в Google Play (не для ручной установки). + + ### Android — версия OneMe (с push-уведомлениями через Firebase) + - **`Komet-android-oneme-arm64-v8a.apk`** — рекомендуется для большинства современных телефонов. + - `Komet-android-oneme-universal.apk` — универсальный APK. + - `Komet-android-oneme-armeabi-v7a.apk` — для старых 32-битных устройств. + - `Komet-android-oneme-x86_64.apk` — для эмуляторов и x86-устройств. + - `Komet-android-oneme.aab` — App Bundle для Google Play. + + ### Windows + - **`Komet-windows-x64.zip`** — портативная сборка для Windows 10/11 (x64). Распаковать и запустить `komet.exe`. + + ### Linux + - **`Komet-linux-x64.tar.gz`** — сборка для 64-битного Linux. Требуются `libgtk-3`, `libsecret-1`, `libjsoncpp`. Распаковать и запустить бинарник. + + ### macOS + - **`Komet-macos.zip`** — `.app` для macOS. Распаковать и перетащить в `/Applications`. Приложение не нотаризовано — при первом запуске разрешите через «Системные настройки → Защита и безопасность». + + ### iOS + - **`Komet-ios-pseudosigned.ipa`** — IPA с псевдо-подписью (ldid). + ⚠️ Не устанавливается через App Store. Подходит для: + - jailbreak (Filza, rootless), + - TrollStore, + - сайдлоада через AltStore / Sideloadly (потребуется ваш Apple ID для повторной подписи). + files: dist/* From ecc45975f71ca376af6ea69064e48439517f7959 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 27 May 2026 23:31:34 +0700 Subject: [PATCH 162/323] =?UTF-8?q?=D0=BD=D0=B5=D0=BC=D0=BD=D0=BE=D0=B6?= =?UTF-8?q?=D0=BA=D0=BE=20=D0=BE=D0=BF=D1=82i=D0=BCi=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B8=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D0=B0=20=D0=B7=D0=B0=20=D0=BA=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 10 +- lib/frontend/screens/chats/chat_screen.dart | 156 +++++++++++++++--- lib/frontend/widgets/adaptive_shell.dart | 11 +- 3 files changed, 148 insertions(+), 29 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 38fb4b9..adbdefa 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -2056,7 +2056,15 @@ class _ChatListScreenState extends State onTap: () { if (_isSelectionMode) { _toggleSelection(id); - } else if (widget.onChatSelected != null) { + return; + } + if (imageUrl.isNotEmpty) { + unawaited(precacheImage( + CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), + context, + )); + } + if (widget.onChatSelected != null) { widget.onChatSelected!(DesktopChatSelection( chatId: int.parse(id), name: name, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 77c61b9..9a2d69d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -97,6 +97,8 @@ class _ChatScreenState extends State String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; late AnimationController _shimmerController; + Timer? _shimmerStartTimer; + bool _historyKickedOff = false; List _messages = []; int _myId = 0; CachedChat? chat; @@ -117,7 +119,7 @@ class _ChatScreenState extends State _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), - )..repeat(); + ); _attachAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 320), @@ -141,9 +143,52 @@ class _ChatScreenState extends State reverseCurve: Curves.easeIn, ); + WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); + } + + void _onFirstFrameRendered(Duration _) { + if (!mounted) return; + if (widget.embedded) { + _kickoffHistory(); + return; + } + final anim = ModalRoute.of(context)?.animation; + if (anim == null || anim.status == AnimationStatus.completed) { + _kickoffHistory(); + return; + } + Timer? safety; + void onStatus(AnimationStatus status) { + if (status != AnimationStatus.completed) return; + anim.removeStatusListener(onStatus); + safety?.cancel(); + if (!mounted) return; + _kickoffHistory(); + } + anim.addStatusListener(onStatus); + safety = Timer(const Duration(milliseconds: 400), () { + anim.removeStatusListener(onStatus); + if (!mounted) return; + _kickoffHistory(); + }); + } + + void _kickoffHistory() { + if (_historyKickedOff) return; + _historyKickedOff = true; + _shimmerStartTimer = Timer(const Duration(milliseconds: 150), () { + if (!mounted || !_isLoading) return; + _shimmerController.repeat(); + }); _loadHistory(); } + void _onLoadingFinished() { + _shimmerStartTimer?.cancel(); + _shimmerStartTimer = null; + if (_shimmerController.isAnimating) _shimmerController.stop(); + } + Future _loadHistory() async { final activeProfile = await AppDatabase.loadActiveProfile(); _myId = activeProfile?.id ?? 0; @@ -157,19 +202,35 @@ class _ChatScreenState extends State unawaited(_loadOtherPresence()); } - final cachedRows = await AppDatabase.loadMessages( + final firstRows = await AppDatabase.loadMessages( + _myId, + widget.chatId, + limit: 20, + ); + if (mounted && firstRows.isNotEmpty) { + final first = firstRows.reversed + .map((r) => CachedMessage.fromDbRow(r)) + .toList(); + setState(() { + _messages = first; + if (api.state == SessionState.online) { + _isLoading = false; + _onLoadingFinished(); + } + }); + } + + unawaited(_loadRemainingHistory()); + } + + Future _loadRemainingHistory() async { + final fullRows = await AppDatabase.loadMessages( _myId, widget.chatId, limit: 100, ); - if (mounted && cachedRows.isNotEmpty) { - setState(() { - _messages = cachedRows.map((r) => CachedMessage.fromDbRow(r)).toList(); - _messages.sort((a, b) => a.time.compareTo(b.time)); - if (api.state == SessionState.online) { - _isLoading = false; - } - }); + if (mounted && fullRows.length > _messages.length) { + _applyMergedMessages(fullRows); } try { @@ -180,13 +241,7 @@ class _ChatScreenState extends State limit: 100, ); if (mounted) { - setState(() { - _messages = updatedRows - .map((r) => CachedMessage.fromDbRow(r)) - .toList(); - _messages.sort((a, b) => a.time.compareTo(b.time)); - _isLoading = false; - }); + _applyMergedMessages(updatedRows, markLoaded: true); } _loadForwardedSenderNames(); } catch (e) { @@ -194,11 +249,53 @@ class _ChatScreenState extends State if (mounted) { setState(() { _isLoading = false; + _onLoadingFinished(); }); } } } + void _applyMergedMessages( + List> rowsDesc, { + bool markLoaded = false, + }) { + final byId = { + for (final m in _messages) m.id: m, + }; + final merged = []; + for (final row in rowsDesc.reversed) { + final fresh = CachedMessage.fromDbRow(row); + final old = byId[fresh.id]; + merged.add(old != null && _sameMessage(old, fresh) ? old : fresh); + } + + final changed = !_listsEquivalent(_messages, merged); + if (!changed && !markLoaded) return; + setState(() { + if (changed) _messages = merged; + if (markLoaded) { + _isLoading = false; + _onLoadingFinished(); + } + }); + } + + bool _sameMessage(CachedMessage a, CachedMessage b) { + return a.id == b.id && + a.time == b.time && + a.status == b.status && + a.text == b.text && + a.senderId == b.senderId; + } + + bool _listsEquivalent(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!identical(a[i], b[i])) return false; + } + return true; + } + @override void dispose() { _messageController.removeListener(_onTextChanged); @@ -221,6 +318,7 @@ class _ChatScreenState extends State _attachAnim.dispose(); _messageController.dispose(); _scrollController.dispose(); + _shimmerStartTimer?.cancel(); _shimmerController.dispose(); super.dispose(); } @@ -906,16 +1004,20 @@ class _ChatScreenState extends State child: bubble, ); - if (message.id == _lastSentId) { - return _SentMessageAnimation( - key: ValueKey('anim_${message.id}'), - onComplete: () { - if (mounted) setState(() => _lastSentId = null); - }, - child: pressable, - ); - } - return pressable; + final Widget child = message.id == _lastSentId + ? _SentMessageAnimation( + key: ValueKey('anim_${message.id}'), + onComplete: () { + if (mounted) setState(() => _lastSentId = null); + }, + child: pressable, + ) + : pressable; + + return RepaintBoundary( + key: ValueKey('msg_${message.id}'), + child: child, + ); }, ), ), diff --git a/lib/frontend/widgets/adaptive_shell.dart b/lib/frontend/widgets/adaptive_shell.dart index c708845..1305c6d 100644 --- a/lib/frontend/widgets/adaptive_shell.dart +++ b/lib/frontend/widgets/adaptive_shell.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -60,6 +63,12 @@ class _AdaptiveShellState extends State { } void _onChatSelected(DesktopChatSelection chat) { + if (chat.imageUrl.isNotEmpty) { + unawaited(precacheImage( + CachedNetworkImageProvider(chat.imageUrl, maxWidth: 144, maxHeight: 144), + context, + )); + } setState(() => _selected = chat); } @@ -84,7 +93,7 @@ class _AdaptiveShellState extends State { return LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth < _breakpoint) { - return ChatListScreen(onChatSelected: _onChatSelected); + return const ChatListScreen(); } final totalWidth = constraints.maxWidth; final effectiveListWidth = _listWidth.clamp( From 58c02003fc9993cef60d4811426c1ba015f8d55d Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 28 May 2026 00:07:27 +0700 Subject: [PATCH 163/323] =?UTF-8?q?=D0=B6=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_swipe_back_desktop.dart | 20 +++ .../screens/chats/chat_list_screen.dart | 15 +- lib/frontend/screens/chats/chat_screen.dart | 14 +- .../screens/chats/create_group_flow.dart | 16 +- .../contacts/contact_profile_screen.dart | 15 +- .../screens/profile/debug_menu_screen.dart | 65 ++++++++ lib/frontend/widgets/swipe_route.dart | 20 +++ lib/frontend/widgets/swipe_to_pop.dart | 141 ++++++++++++++++++ lib/main.dart | 2 + 9 files changed, 282 insertions(+), 26 deletions(-) create mode 100644 lib/core/config/app_swipe_back_desktop.dart create mode 100644 lib/frontend/widgets/swipe_route.dart create mode 100644 lib/frontend/widgets/swipe_to_pop.dart diff --git a/lib/core/config/app_swipe_back_desktop.dart b/lib/core/config/app_swipe_back_desktop.dart new file mode 100644 index 0000000..9dffd1f --- /dev/null +++ b/lib/core/config/app_swipe_back_desktop.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppSwipeBackDesktop { + static const prefKey = 'dev_swipe_back_desktop'; + static const bool defaultValue = false; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? defaultValue; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index adbdefa..655e5f6 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -10,6 +10,7 @@ import 'chat_screen.dart'; import 'create_group_flow.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/swipe_route.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -2072,15 +2073,13 @@ class _ChatListScreenState extends State chatType: chatType, )); } else { - Navigator.push( + pushSwipeable( context, - MaterialPageRoute( - builder: (context) => ChatScreen( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), + (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, ), ); } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 9a2d69d..6dcd73d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -21,10 +21,12 @@ import '../../../core/storage/app_database.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_message_actions_style.dart'; +import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; +import '../../widgets/swipe_to_pop.dart'; class _UploadStatus { final bool active; @@ -791,10 +793,17 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - + // TODO: Локализация // TODO: Cклонения - return Scaffold( + return ValueListenableBuilder( + valueListenable: AppSwipeBackDesktop.current, + builder: (context, desktopSwipe, child) => SwipeToPop( + enabled: widget.embedded && desktopSwipe, + onPop: widget.onClose, + child: child!, + ), + child: Scaffold( backgroundColor: cs.surface, appBar: PreferredSize( preferredSize: Size.fromHeight(kToolbarHeight), @@ -942,6 +951,7 @@ class _ChatScreenState extends State _buildInputArea(context), ], ), + ), ); } diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index fed22a6..3f6b3ea 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -10,6 +10,7 @@ import '../../../backend/modules/contacts.dart'; import '../../../core/storage/token_storage.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/swipe_route.dart'; import 'chat_screen.dart'; const int _maxAvatarBytes = 8 * 1024 * 1024; @@ -146,14 +147,13 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { if (!mounted) return; navigator.pop(); - navigator.push( - MaterialPageRoute( - builder: (_) => ChatScreen( - chatId: chat.id, - name: chat.title ?? title, - imageUrl: chat.iconUrl ?? '', - chatType: chat.type, - ), + pushSwipeable( + context, + (_) => ChatScreen( + chatId: chat.id, + name: chat.title ?? title, + imageUrl: chat.iconUrl ?? '', + chatType: chat.type, ), ); } catch (e) { diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index e0051b4..e502a18 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -7,6 +7,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/swipe_route.dart'; import '../chats/chat_screen.dart'; class ContactProfileScreen extends StatefulWidget { @@ -170,15 +171,13 @@ class _ContactProfileScreenState extends State { ); final chatId = existing ?? (accountId ^ widget.contactId); if (!mounted) return; - Navigator.push( + pushSwipeable( context, - MaterialPageRoute( - builder: (_) => ChatScreen( - chatId: chatId, - name: _displayName(), - imageUrl: _avatarUrl() ?? '', - chatType: 'DIALOG', - ), + (_) => ChatScreen( + chatId: chatId, + name: _displayName(), + imageUrl: _avatarUrl() ?? '', + chatType: 'DIALOG', ), ); } diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index ed594bc..b607079 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; +import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; @@ -332,6 +333,70 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppSwipeBackDesktop.current, + builder: (context, swipeOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.swipe_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Свайп-назад в десктоп-режиме', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Включает жест «провести от левого края, чтобы ' + 'закрыть» внутри встроенной панели чата на ' + 'десктопе — для тестирования курсором', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: swipeOn, + onChanged: (v) { + AppSwipeBackDesktop.save(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/widgets/swipe_route.dart b/lib/frontend/widgets/swipe_route.dart new file mode 100644 index 0000000..933ad50 --- /dev/null +++ b/lib/frontend/widgets/swipe_route.dart @@ -0,0 +1,20 @@ +import 'package:flutter/cupertino.dart'; + +class SwipeRoute extends CupertinoPageRoute { + SwipeRoute({ + required super.builder, + super.settings, + super.maintainState, + super.fullscreenDialog, + }); +} + +Future pushSwipeable( + BuildContext context, + WidgetBuilder builder, { + RouteSettings? settings, +}) { + return Navigator.of(context).push( + SwipeRoute(builder: builder, settings: settings), + ); +} diff --git a/lib/frontend/widgets/swipe_to_pop.dart b/lib/frontend/widgets/swipe_to_pop.dart new file mode 100644 index 0000000..3c14093 --- /dev/null +++ b/lib/frontend/widgets/swipe_to_pop.dart @@ -0,0 +1,141 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class SwipeToPop extends StatefulWidget { + final Widget child; + final double edgeWidth; + final double popThreshold; + final double velocityThreshold; + final bool fullWidth; + final bool enabled; + final VoidCallback? onPop; + + const SwipeToPop({ + super.key, + required this.child, + this.edgeWidth = 28, + this.popThreshold = 0.35, + this.velocityThreshold = 700, + this.fullWidth = false, + this.enabled = true, + this.onPop, + }); + + @override + State createState() => _SwipeToPopState(); +} + +class _SwipeToPopState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + bool get _canPop { + if (!widget.enabled) return false; + if (widget.onPop != null) return true; + final route = ModalRoute.of(context); + if (route == null) return false; + return route.canPop; + } + + void _onDragStart(DragStartDetails _) { + _controller.stop(); + } + + void _onDragUpdate(DragUpdateDetails d, double width) { + if (width <= 0) return; + final next = (_controller.value + d.delta.dx / width).clamp(0.0, 1.0); + _controller.value = next; + } + + Future _onDragEnd(DragEndDetails d, double width) async { + final velocity = d.primaryVelocity ?? 0; + final pastThreshold = _controller.value > widget.popThreshold || + velocity > widget.velocityThreshold; + if (pastThreshold) { + final remaining = 1.0 - _controller.value; + _controller.duration = Duration( + milliseconds: (remaining * 220).clamp(80, 220).round(), + ); + await _controller.animateTo(1.0, curve: Curves.easeOutCubic); + if (!mounted) return; + if (widget.onPop != null) { + widget.onPop!(); + } else { + Navigator.of(context).maybePop(); + } + } else { + _controller.duration = const Duration(milliseconds: 200); + await _controller.animateBack(0.0, curve: Curves.easeOutCubic); + } + } + + void _onDragCancel() { + _controller.duration = const Duration(milliseconds: 200); + _controller.animateBack(0.0, curve: Curves.easeOutCubic); + } + + @override + Widget build(BuildContext context) { + if (!_canPop) return widget.child; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final gestureChild = GestureDetector( + behavior: HitTestBehavior.translucent, + dragStartBehavior: DragStartBehavior.down, + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: (d) => _onDragUpdate(d, width), + onHorizontalDragEnd: (d) => _onDragEnd(d, width), + onHorizontalDragCancel: _onDragCancel, + ); + + return Stack( + children: [ + Positioned.fill( + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; + return Transform.translate( + offset: Offset(t * width, 0), + child: Opacity( + opacity: (1.0 - t * 0.35).clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: widget.child, + ), + ), + if (widget.fullWidth) + Positioned.fill(child: gestureChild) + else + Positioned( + left: 0, + top: 0, + bottom: 0, + width: widget.edgeWidth, + child: gestureChild, + ), + ], + ); + }, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 74485e1..905dedb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; import 'core/config/app_message_actions_style.dart'; +import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -92,6 +93,7 @@ void main() async { AppAmoled.current.value = await AppAmoled.load(); AppThemeSchedule.current.value = await AppThemeSchedule.load(); AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load(); + AppSwipeBackDesktop.current.value = await AppSwipeBackDesktop.load(); runApp( KometApp( initialLocale: initialLocale, From 757e649f94b9a202efc1d3c4a9d8cc65f7929f14 Mon Sep 17 00:00:00 2001 From: klockky Date: Wed, 27 May 2026 17:22:59 +0000 Subject: [PATCH 164/323] =?UTF-8?q?feat:=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B6=D0=B0=D0=B5=D0=BC=D0=BE=D0=B5=20=D0=B8=D0=BC=D1=8F?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=BE=D0=B9=20=D0=B1?= =?UTF-8?q?=D1=83=D0=BA=D0=B2=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-main.yml | 2 +- android/app/src/main/AndroidManifest.xml | 2 +- ios/Runner/Info.plist | 2 +- linux/CMakeLists.txt | 2 +- linux/runner/my_application.cc | 4 ++-- macos/Runner/Configs/AppInfo.xcconfig | 2 +- windows/CMakeLists.txt | 2 +- windows/runner/Runner.rc | 8 ++++---- windows/runner/main.cpp | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml index c1d8c51..adc94f8 100644 --- a/.github/workflows/release-main.yml +++ b/.github/workflows/release-main.yml @@ -276,7 +276,7 @@ jobs: - `Komet-android-oneme.aab` — App Bundle для Google Play. ### Windows - - **`Komet-windows-x64.zip`** — портативная сборка для Windows 10/11 (x64). Распаковать и запустить `komet.exe`. + - **`Komet-windows-x64.zip`** — портативная сборка для Windows 10/11 (x64). Распаковать и запустить `Komet.exe`. ### Linux - **`Komet-linux-x64.tar.gz`** — сборка для 64-битного Linux. Требуются `libgtk-3`, `libsecret-1`, `libjsoncpp`. Распаковать и запустить бинарник. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a3b1ba5..06e92c7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - komet + Komet CFBundlePackageType APPL CFBundleShortVersionString diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index ed7975d..106dcd4 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -4,7 +4,7 @@ project(runner LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "komet") +set(BINARY_NAME "Komet") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID set(APPLICATION_ID "ru.komet.app") diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 380209e..832e416 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "komet"); + gtk_header_bar_set_title(header_bar, "Komet"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "komet"); + gtk_window_set_title(window, "Komet"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index e8f873a..000aa53 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = komet +PRODUCT_NAME = Komet // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 7b65d96..2667668 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -4,7 +4,7 @@ project(komet LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "komet") +set(BINARY_NAME "Komet") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 9427177..223e5d4 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -90,12 +90,12 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "ru.komet" "\0" - VALUE "FileDescription", "komet" "\0" + VALUE "FileDescription", "Komet" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "komet" "\0" + VALUE "InternalName", "Komet" "\0" VALUE "LegalCopyright", "Copyright (C) 2026 ru.komet. All rights reserved." "\0" - VALUE "OriginalFilename", "komet.exe" "\0" - VALUE "ProductName", "komet" "\0" + VALUE "OriginalFilename", "Komet.exe" "\0" + VALUE "ProductName", "Komet" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index e9e529a..3761490 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"komet", origin, size)) { + if (!window.Create(L"Komet", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); From b35626e36a0258ef6f17d238508fcb2ea57c9b7b Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 28 May 2026 04:39:38 +0000 Subject: [PATCH 165/323] =?UTF-8?q?feat:=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=BE=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20(Default=20/=20Minimal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 15 ++ .../drawable-hdpi/ic_launcher_foreground.png | Bin 0 -> 9782 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 0 -> 5163 bytes .../drawable-xhdpi/ic_launcher_foreground.png | Bin 0 -> 13873 bytes .../ic_launcher_foreground.png | Bin 0 -> 26663 bytes .../ic_launcher_foreground.png | Bin 0 -> 44030 bytes .../res/mipmap-anydpi-v26/ic_launcher.xml | 9 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 544 -> 2799 bytes .../res/mipmap-hdpi/ic_launcher_minimal.png | Bin 0 -> 2603 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 442 -> 1510 bytes .../res/mipmap-mdpi/ic_launcher_minimal.png | Bin 0 -> 1453 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 721 -> 3936 bytes .../res/mipmap-xhdpi/ic_launcher_minimal.png | Bin 0 -> 3687 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 1031 -> 7033 bytes .../res/mipmap-xxhdpi/ic_launcher_minimal.png | Bin 0 -> 6612 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 1443 -> 10295 bytes .../mipmap-xxxhdpi/ic_launcher_minimal.png | Bin 0 -> 10470 bytes android/app/src/main/res/values/colors.xml | 4 + assets/komet_icon.png | Bin 0 -> 171671 bytes assets/meteor.png | Bin 0 -> 69540 bytes assets/meteor_icon.png | Bin 0 -> 64932 bytes ios/Runner.xcodeproj/project.pbxproj | 12 +- .../AppIcon.appiconset/Contents.json | 123 +------------ .../Icon-App-1024x1024@1x.png | Bin 10932 -> 159694 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 295 -> 439 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 406 -> 1078 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 450 -> 1910 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 282 -> 700 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 462 -> 1816 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 704 -> 3221 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 406 -> 1078 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 586 -> 2847 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 862 -> 5030 bytes .../AppIcon.appiconset/Icon-App-50x50@1x.png | Bin 0 -> 1469 bytes .../AppIcon.appiconset/Icon-App-50x50@2x.png | Bin 0 -> 3918 bytes .../AppIcon.appiconset/Icon-App-57x57@1x.png | Bin 0 -> 1783 bytes .../AppIcon.appiconset/Icon-App-57x57@2x.png | Bin 0 -> 4682 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 862 -> 5030 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 1674 -> 8980 bytes .../AppIcon.appiconset/Icon-App-72x72@1x.png | Bin 0 -> 2454 bytes .../AppIcon.appiconset/Icon-App-72x72@2x.png | Bin 0 -> 6488 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 762 -> 2671 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 1226 -> 6978 bytes .../Icon-App-83.5x83.5@2x.png | Bin 1418 -> 8034 bytes ios/Runner/Info.plist | 15 ++ ios/Runner/MinimalIcon@2x.png | Bin 0 -> 5103 bytes ios/Runner/MinimalIcon@3x.png | Bin 0 -> 10211 bytes lib/core/config/app_icon.dart | 51 ++++++ .../screens/profile/app_icon_screen.dart | 169 ++++++++++++++++++ .../screens/profile/customization_screen.dart | 9 + .../AppIcon.appiconset/Contents.json | 132 +++++++------- .../AppIcon.appiconset/app_icon_1024.png | Bin 102994 -> 171671 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 5680 -> 5789 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 520 -> 389 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 14142 -> 16212 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 1066 -> 881 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 36406 -> 54369 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 2218 -> 2292 bytes pubspec.lock | 76 +++++++- pubspec.yaml | 20 +++ tool/make_icon_bg.dart | 20 +++ tool/make_minimal_android.dart | 25 +++ tool/make_minimal_ios.dart | 19 ++ windows/runner/resources/app_icon.ico | Bin 33772 -> 16234 bytes 64 files changed, 503 insertions(+), 196 deletions(-) create mode 100644 android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_minimal.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_minimal.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_minimal.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_minimal.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimal.png create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 assets/komet_icon.png create mode 100644 assets/meteor.png create mode 100644 assets/meteor_icon.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png create mode 100644 ios/Runner/MinimalIcon@2x.png create mode 100644 ios/Runner/MinimalIcon@3x.png create mode 100644 lib/core/config/app_icon.dart create mode 100644 lib/frontend/screens/profile/app_icon_screen.dart create mode 100644 tool/make_icon_bg.dart create mode 100644 tool/make_minimal_android.dart create mode 100644 tool/make_minimal_ios.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 06e92c7..83e4929 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,9 @@ android:label="Komet" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + + + + + + + +zQKeBW^x-WZ9`)yZL=H^lc5zr-Fm3DJXW?Y@U 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..16320fb7f80bd6bc3ee529a899cd913c50160842 GIT binary patch literal 5163 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 T7s`SU0D_Q_P!z8g{TcLs?-)wt literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..a957641fd4c93d7a4dc4cf5230a7b99b14a07768 GIT binary patch literal 44030 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 Nw77y;m56@e{{z*8^|Sy0 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4b7b0906d62b1847e87f15cdcacf6a4f29..c2a7c52cc833b01183f679672359b284633453f0 100644 GIT binary patch literal 2799 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 BR!#r_ literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_minimal.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_minimal.png new file mode 100644 index 0000000000000000000000000000000000000000..2c0aee9bd4c46e04efd319f4c73bdabd9561ea0c GIT binary patch literal 2603 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 Hu0mjfq8F?% literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391482be68e9e4a07fab769b5de337d16eb1..49b271a1cc333d59f8e29a24dd7231c561a17360 100644 GIT binary patch literal 3936 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 OXMmBuxn8ZVJN7^CBy_O= literal 721 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!iOI#yLg7ec#$`gxH85~pclTsBt za}(23gHjVyDhp4h+5i=O3-AeX1=1l$e`s#|#^}+&7(N@w0CIr{$Oe+Uk^K-ZP~83C zcc@hG6rikF&NPT(23>y!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_minimal.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_minimal.png new file mode 100644 index 0000000000000000000000000000000000000000..52f60f489ef8d9ae8d9c09cb6d464dfd2df50cc2 GIT binary patch literal 3687 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 aza)WcH@sPz$Ao_mBtTo;Kn@b*a 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 Rd9&2ulod4O>t!v%{|j>ITs8mz literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_minimal.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_minimal.png new file mode 100644 index 0000000000000000000000000000000000000000..1074637debbe89ca84c4152a1eac41df013c2d34 GIT binary patch literal 6612 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 PZw-bj2%=P}U0Ikc}Pq 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 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimal.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_minimal.png new file mode 100644 index 0000000000000000000000000000000000000000..54b9cdec96ab1dbb9a9831c9147ec36da911ba78 GIT binary patch literal 10470 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 aL>6brG2Kjs?X!OoLQYCqvPRrE + + #000000 + \ No newline at end of file diff --git a/assets/komet_icon.png b/assets/komet_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a8c3408c8f984a1146b0eab5fd807cc2557bead0 GIT binary patch literal 171671 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 F{{w}yX@vj) literal 0 HcmV?d00001 diff --git a/assets/meteor_icon.png b/assets/meteor_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f356a2cbf31987e89ac9364ed123017630e4672b GIT binary patch literal 64932 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 literal 0 HcmV?d00001 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 5b0a064..b6d29d3 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png */; }; + B1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png */; }; E835CC948F2F948DC3BBF405 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ @@ -62,6 +64,8 @@ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "MinimalIcon@2x.png"; sourceTree = ""; }; + A1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "MinimalIcon@3x.png"; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; @@ -143,6 +147,8 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + A1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png */, + A1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -264,6 +270,8 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + B1B2C3D41234567890ABCDEF /* MinimalIcon@2x.png in Resources */, + B1B2C3D41234567890ABCDF0 /* MinimalIcon@3x.png in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -539,7 +547,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -596,7 +604,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..d0d98aa 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4725e9b0ddb1deab583e5b5102493aa332..13ff2cc022a653aa31ee386c9a2908baf6adc6f5 100644 GIT binary patch literal 159694 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 zΠ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±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

Wu=8O z($WteJS-?Gdw=%)IZn+G@ZrOUqlb^!J2}64{aRmNS4dFs)vH$w4F7$+J!Z|BPe3yR z1H=3G@2*|FA|xWh&dm7i*|XPg-hTM_<<*%J$ zuU_vzaEK_)P$Pc+{Ppwal&RDB1q42R_&^}Qu!6sT{~^A6`xZ@bz;o3AXa)fKd#kYZ S8qzNS00001E&Iz8Gi!+006pI?LPnj0Blf9R7L;)|5U~J`u_j-{Qm)0oAmqtj@kOz z^8J|I`-|B6ht~R5kG+%I`zf~eztraM`u^bc{`dO)zUlmg)%x%C`E}6wSI77~z4s`y z^XT{f(eM4n?EUff`e@AgO~UxV*5*r_%Uhbj5N)LaQj!wdIe!-b004GLL_t&-)18pX z4udcZ1u-#g(~z+5JN*AY5?>Gw7hsN~k)CYt4dQDFxbs5*_&e@Hj)wtt(&JE<3Eq*D z;_gQLvqXoKv=I*gWqM9C(Tvu0>=?hTbOp9!6k6AF;>f6|S5%jGEE}TA9h)e`Yuiu8 d7)l?o1NFcJg%EAfM$P~L002ovPDHLkV1g~!nw9_n diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452e458972bab9d994556c8305db4c827017..0aa67dec1dfd48ce2e4bc270a543d07b076418c9 100644 GIT binary patch delta 1068 zcmV+{1k?ML1GWf|8Gix*000A=FFF7K1N})vK~#90?bcgN990;=@iWV1v(R>dwacYK zg~oz{_^=cNS_O%u2@!(T*a}D~X=3_NQ%!vE!8Em%1`8;OVnq=t1(Iq@)yEc^MoP;~ z+HxtZl*{h2%M8oT%wA?@&&^a49xNtGM@`KBA168U%bA?X`G11Je^hV)4!{BU{{@GH zgzQL4LKrPMmfRlCzXwAIr_JW9%w2~LRwO4UFWMdB;}d;1`p3q`DW!i{$uJB6;Nj;R zfdK%7kp7#u`fuJ!Onl;%(vp{p_8zXTLYQ2+a`p0$KMf8Jtx1&943i0C^vmdN72vgI zXXonbKfQSAa({W*{%6zE^7Hn*y02tz-qO+0_1(p8uh$Cz7#bd~uByz-d;asLFBL^m z2VUbpJb5}ZGvoEw_Ql4=B*e$RbL?pKp@Z!mo#)y+B}r;&ZL9mZ=Cj6z<0np8Z8p{5 z)dmKJ?%cjxT3Vc&^PIt8NQjTGs;t;s_)=?I`_-P_o`2qJ)m4@CXKGGywXCB;RGqOcsN*Xxge@c!xA`hmeAHR08!rp>iy zK6$g^4Oy1A#l`9M`siq*zze(}uq><7>E1qiq^_ZHWMotwSgX})G#W{g003^cyXlMO zit+;pp?}Srw-}9&>B4lH;2@6WL{a2tvPjq5W;dCgb)BwQe1=(I@|oU3h-*P_h%GE-u*(p zHh&ZW0Bk(Zvg~{B9zzJZTrNTgr4&L45iBfx@ly8>EXSz>8;!<@h)4(_K^S4Qu&}Uw zd%~to;ZCO$Ap{{5MG+zdCEfIRZgd|CMaNKyF5F*PmFYt=& z7u{Y=ur@T*?}rb+VdH~4Pk%{G4bf_?R@?miLU3@R|HA{!dP^B zXJ+oJ39q(jv)UJJF|lzF!VBMb7Zl_pg#CU$A=K^j`XqVk-juN3Po=`>Ln*Z{KDd{9 zdaqFco=H!qluEJ$5&m`Be50>#-BoJNT$IwDp5CG1k?80c3JABvuDe(D;nhSz5GDWI m`v>8_SO5;d0XP6}xZeOiS2G)RbU)+(0000+>eB z?J{?+FLkYu+4_Uk`r_>LHF~flZm0oBf#vr8%vJ>#p~!KNvqGG3)|f1T_)ydeh8$vDceZ>oNbH^|*hJ*t?Yc*1`WB&W>VYVEzu) zq#7;;VjO)t*nbgf(!`OXJBr45rP>>AQr$6c7slJWvbpNW@KTwna6d?PP>hvXCcp=4 zF;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f{(`6ZU6uP diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d933e1120817fe9182483a228007b18ab6ae..187815098829ac9f1df2f92ee9ca28c651983dd5 100644 GIT binary patch delta 1906 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 delta 266 zcmV+l0rmd81)2ho8Gi!+003c4mpuRg09{Z_R7L;)|5U~JDYo_jSDX9(|7FYh`2GLd z^Zv2r{H^2sT*&w!Y^SB+`<>qVZqE6)=lqo0`vF#&*75!I`TIh@_d&k*HoEtQyV-iD z%Xz2D9EQRbeYh5Nr~y=#0ZD;^+vz0$004MNL_t(2&&|%+4u6C&2tZM$Wf&dzefR%A z(^3-?6X>hnCz2Ba@RH&`m!pgy?n@#@AuLYB&}Q)FGY`?vcft0!vht0Z@M&ZeNCWXh75gzRTXR8EE3oN&6 Q00000NkvXXt^-0~f*bvXvj6}9 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe730945a01f64a61e2235dbe3f45b08f7729182..7620bbb811e71c3628fc91a7b5456f5ef539853e 100644 GIT binary patch delta 1812 zcmV+v2kZFG1DFnw8Gix*003^;-G2Z82H#0UK~#90?U;FR6W1BQ->xo8TFEQlw~a__ zWCMwh6q9h6P$r>y`BM5@i=`1cT&d8zYT=;0I zamkto>I(|;es5)wv8vsE>1i?8!Pt$aER_2RGo;!5-=)}a;BAt$^ zFD#baoSfX;T#PYAQ3{2^<#vmrh!AqQTw1M$rs)Ge|LK8)&re>vmK*GKc6H?f+VuDv;8p;LM+QM3`bGgXP@1F@Zj@yha*9{6h%d1+v^W>}Wv_`JMqjYb0i&}uabh1}(G3xa?#_IkYpA=|g_`^S#I!Z1uCbV-t2 zzkc0{<;xj{nVFrlI~;bq-RtpKER#Nl)qiOzK%ki!M@EL;V4yL^1VLnGX1HALa99L@ z6h#OyxufZ^Q}6v2V;px~hG8yV?E3W6&uZ&xYietkuUru=H82Jkgd(Be@t078(ir%;TZ-rmm6E`-q1rN-6ut5j(!yUp>pfq`H!7 zj^ny7c3qvAcwp^Xold7xDggmRh`oM>*6B!+^!r(ikXo&J{f)QB#;zEROAa48a9>@G zRQ(Bp;PWxRIeF^znX_M;7`N>-F@Iy7`LN}}>igFe78NMdR7!;c0kFU@w4Ndfna|I* zwSO`;cBQta`pq|frPXLe5%PS1=Yt4E6XnT0yNZemj=uCVLMRcsQo-5X)mdFrTV7eI zkdtb)8j~c$1$0_1%P>8?eHjMBD<_Vtlu8Iizn>8V;f5T9@cz}S9@)O_kAE#6CP-Ie z=<*;!sG_P$5CfDZE&3X=(9rM*gmCZPJ?ZIsjIrO(L?RLVZxBL)C_o6eKfLXukJ|-7 zNSrPJV02`-xU@{IQL_xE(P}UNlh>vI01s|hFNz||vEfiyO4CRr!t=Z+iqXxEB$P^J zMMe4e_>~0d=H%ooGnulobAM$jB^Dt<$j5RtK`;zM5ComB_jo)ILJTlQnBzF9q>QQ) zgp|bTdi^5n-)7zU7$v2prm7WMib7E<%msLYATW;l5izak51C^F0Ult?asi$XL4=e_ zg<7rpk5fr}btSCR(=%3UNm*HWg(*#|35CKMjh6BIzpzXeU|5Co6geXFzX z{D$c_nUrokN$JLulx{pp>Bf_kZahip#*;ft_n&>S#F$&rYYmeC00005 ziGV;=={^- z?sLQGb)?A{hr$_!z8HbH7kH=vM0x-*R~t>;jsO4v^GQTOR7l6|(&r9>FcgO2dg?%> z;=sK?5%;?Pn^T7LL?Y$@5u?06NuIR*0?Yf$Hf5Afk+lM<^ch*jvO$sU*m9J?JI7eI zGFV6+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9R%3*Q+)t%S!MU_`id^@& zY{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&p6kME1_Z%?`+u)^el0!1<0sd p?Eyu!OMLDifi)An*I;?S-wj=m4RYIt!kPd8002ovPDHLkV1o5}^cMgC diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773cd857a8a0f0c9c7d3dc3f5ff4fb298dc10..1d4531f1d385d1701937e71da8313a99cec42548 100644 GIT binary patch literal 3221 zcmai1=|9u~_x)O`F_f`nDQjd&7=w^~$}skQ$sS^2?8Vqa)GU3wsm!*VbU#18DleD6 z8B{2L^Bfz`)3~Kpa;1+*?2mwSIC&15VyIN3Cxz}G9=?Gdxq-@TwwnptEX?JGVduT< zhq(qYGb7Q{Ma=;A z$4^ATT4j*T+jM%I@JtCH8Ls#L;N>a_El_z`8U3RVLKE@|2a;eOw>oRpkb%Nv4)KrF zmpwn<-ubb%H$Wtc83gSJT~e7n$m4fy`N5o;0!~c{3<})dS(I^Zwyd_SQM^=PPH}Cp zu^F?pwz4X&^TFAkCC}2C`5ni;Zyt59Z} zuJ$N60Et4O>=M6>iZf#ofw?9P-(Y_MU|n9gl(pw{AhDCPa4r( z-vPH!-xo5&1k|cW{}dEA=2L%&udnY^a%*#_*A>LPti{(>5+`Tu99|!Ah(L+dDye=G z8$-NDx4OYb|3|$|T5C4ALbA&p%7gIqOdlAC*bd1Uqd02>{vP*y!H;WhI_~x2Bdt%h zY*t7Y6r~T;{g?@8pBjS}MXlstp@w)$5mfC}mPhSv-rVtpt`y{7lFq4o`SN9N?`@Da zBCKDqIO3=!Lo;l}@>3V9#x-fF9-h-BWj<0}r0;Qhou9xY;SbfuNmfR(gX{G{DQJOt zrdY*dHT|F1q7!q8I@jdpAp27)54?LfoOe?rC z=Ioo4gwcGh_bXi>W3l7-+{r%G=Cw`T=YB&^mHN`s(xxWGt15O?Bd%CB_@$Na~kz~oTkb( zkeZg3{V7RYAYAk+W?^()l?~3;vUC5uC4a#7>YG6;8(BiM-N2F|5Hqs8wy|N1L=KTi zBJuvFG#ZT%J@@Vi)4<9tpn}=vBnQkrE4m;~w6)a$$v)S#DW~AKS1z;kuso?|us2dE z8bJRDG0an2o~|$`E6ci09vvGCnOOfPTt?v@TbEK(i9m!WiL}Zk^HI~v;f|f1PQUy8 zrkh%3t>lVSig0|TQC=fzKEbGkr| zRZ(R}8M#u3ohc{fUK5c>tWmsQKD3p)>pg}ZA0OfPc@${ce!Xc?7Qc63&#K-!;jSM8 znOJ}LVk~^bNa>S1%$3-cOzK;2|-<9#+lgPjd_*jDtaS z($aEr6Y-30Ulq?<#LbqOA|&~XE<|K|C7}HpST9#b{w;N0AAc`penYpIUdN0>+_><@ zgBk~!ptd$2{$^`$@7GOVhQ9L-q(k7`vQGN2JjRqeG{eouv>l(TJi_sci35wmFZYq5}c~Fqr-EcWz=P`GrO5*W{%* zw39-ofBKK3A7Z0H8}CltGZe-7=(crt792B;HBTui?QJ&RPUC z2Eg$JDi@X`Jo3K$O0t||pmHmxtA;n@aJXyYN}dzHmnWX{(7UoKAm@b zRg_m}5``wa(W5uYhhCXV)c0Os1imc?U+`=hTs{8vnYZD6b4Q0dPG$E)m@f9+d!j=~ zweL|plN%|`>RL7|4=V(ssWZ6FJ-^E{otA)71hnFX=d^Vbd%UpX$UJ2$kgztygGwcx zw$FWC4%V+2UVrEuj_1MhbU{BWUA&kp<5Vc;ozvwdgfuTT1qr&)&%LB24t_`8B(#*c&^ien1n)o7Q9e1^U$`Og?3tpb zX4|UMxf^J#HDXx5!rVAhLU{);5{YqBSed<8f+A9=DuD}0NkWQOJq9=UyBwZz=rS`( z5M~AgN)3t^H|^U*)zu#yCvZ!otUPnQgcdIf zbE@f>n3%LKu?s>*(CLei4Brlq?$5j}MF%#>&2`@Xk%^lA2DQOOEN_qS8g0KkJH>_l zF}^d#`qnu51}#QhNlDyqlm&R3RMOtAif67jmSTg`KNd=_Fvy*RasmKlv2*Oc8$wK+ z8R;4GOU0=!84>empMhKU@r4H)Z9!~syz)O1zTkY6W1S@BLeuA!owROg#p*_6e#(0K zROV*ZwAYf!hOC3KX2gOXq!3~x&xeq^q(}}xqpfL=sx8ErH}mH4PO_wcpZa|8k8iPK z^Bz26Yuvq+VxmcFW_DwWXF#Q0UWP_Sl)k>FFifL$oN%uieV60FFbktrxpjE`?=f~P zvA9?u!L>UbVCrMs+mDDMI8Ta|E`j=vV$VWqC@a$i3XGgk;=_Wv5nn+$7-p@N2GJ4_ zhOdc%9@Fog@iLd;7Dr^do0x=z(h{{pJDgq&a_^`C#KSJ#f!$9TA^|kBMMKV+UA#a zIQf12x0&)-GL=iI?_$F9MY*YUl$Xz12k)&-{=N#Q=;gZ>J=AZ%v5lV%OG|ofIXFb3 z!nPNxF+n@MsJJbM$8OrA6iS-hqc^`rZW93LJ)X+yYF5qb?`0&C=9KF<=FS+n-5Pzh z#wICA3yTXS)Z(xBjjls&)#3}xPP9I(?g}%~Y0cd!;qBLDV&aK^TY3`s!Z@H_1YE|G zP+hGNvL6_E2a$j%-wjhoudnx2T%0GRam*-?18hjleAv7V7kFA+?)4t)LXD{^*^Ui~ zB=FzkUqOrAxi187)-vnc?tqf{!KE-#lJ59A&EJOoe!M>lnM@jKv8Y|m>Sn!d_U%!e zASdUf8!S&K-r;S@taAE2i8rU3RFPZqSNr;MDl0qFquop)(OmwsAG$tSRAL9dFoh@j z3orrA561fq$OmHycvp!e{`hSiEXc^{PFUNti|K1cTd%z^M`IgrgVYW}TIFSItVGKX uM=Lwt?{a=>U4br2|=<_Wb|z`~RBV`-<24{r>;E==`tb{CU#(0alua*7{P! z_>|iF0Z@&o;`@Zw`ed2Hv*!Fwin#$(m7w4Ij@kM+yZ0`*_J0?7s{u=e0YGxN=lnXn z_j;$xb)?A|hr(Z#!1DV3H@o+7qQ_N_ycmMI0acg)Gg|cf|J(EaqTu_A!rvTerUFQQ z05n|zFjFP9FmM0>0mMl}K~z}7?bK^if#bc3@hBPX@I$58-z}(ZZE!t-aOGpjNkbau@>yEzH(5Yj4kZ ziMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_stABAHe$v|ToifVv60B@podBTcIqVcr1w`hG7HeY|fvLid#^Ok4NAXIXSt1 Zxpx7IC@PekH?;r&002ovPDHLkV1l%3Yd!z~ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452e458972bab9d994556c8305db4c827017..0aa67dec1dfd48ce2e4bc270a543d07b076418c9 100644 GIT binary patch delta 1068 zcmV+{1k?ML1GWf|8Gix*000A=FFF7K1N})vK~#90?bcgN990;=@iWV1v(R>dwacYK zg~oz{_^=cNS_O%u2@!(T*a}D~X=3_NQ%!vE!8Em%1`8;OVnq=t1(Iq@)yEc^MoP;~ z+HxtZl*{h2%M8oT%wA?@&&^a49xNtGM@`KBA168U%bA?X`G11Je^hV)4!{BU{{@GH zgzQL4LKrPMmfRlCzXwAIr_JW9%w2~LRwO4UFWMdB;}d;1`p3q`DW!i{$uJB6;Nj;R zfdK%7kp7#u`fuJ!Onl;%(vp{p_8zXTLYQ2+a`p0$KMf8Jtx1&943i0C^vmdN72vgI zXXonbKfQSAa({W*{%6zE^7Hn*y02tz-qO+0_1(p8uh$Cz7#bd~uByz-d;asLFBL^m z2VUbpJb5}ZGvoEw_Ql4=B*e$RbL?pKp@Z!mo#)y+B}r;&ZL9mZ=Cj6z<0np8Z8p{5 z)dmKJ?%cjxT3Vc&^PIt8NQjTGs;t;s_)=?I`_-P_o`2qJ)m4@CXKGGywXCB;RGqOcsN*Xxge@c!xA`hmeAHR08!rp>iy zK6$g^4Oy1A#l`9M`siq*zze(}uq><7>E1qiq^_ZHWMotwSgX})G#W{g003^cyXlMO zit+;pp?}Srw-}9&>B4lH;2@6WL{a2tvPjq5W;dCgb)BwQe1=(I@|oU3h-*P_h%GE-u*(p zHh&ZW0Bk(Zvg~{B9zzJZTrNTgr4&L45iBfx@ly8>EXSz>8;!<@h)4(_K^S4Qu&}Uw zd%~to;ZCO$Ap{{5MG+zdCEfIRZgd|CMaNKyF5F*PmFYt=& z7u{Y=ur@T*?}rb+VdH~4Pk%{G4bf_?R@?miLU3@R|HA{!dP^B zXJ+oJ39q(jv)UJJF|lzF!VBMb7Zl_pg#CU$A=K^j`XqVk-juN3Po=`>Ln*Z{KDd{9 zdaqFco=H!qluEJ$5&m`Be50>#-BoJNT$IwDp5CG1k?80c3JABvuDe(D;nhSz5GDWI m`v>8_SO5;d0XP6}xZeOiS2G)RbU)+(0000+>eB z?J{?+FLkYu+4_Uk`r_>LHF~flZm0oBf#vr8%vJ>#p~!KNvqGG3)|f1T_)ydeh8$vDceZ>oNbH^|*hJ*t?Yc*1`WB&W>VYVEzu) zq#7;;VjO)t*nbgf(!`OXJBr45rP>>AQr$6c7slJWvbpNW@KTwna6d?PP>hvXCcp=4 zF;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f{(`6ZU6uP diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463a9bc882b461c96aadf492d1729e49e725..2f9be0b7492452663b57caf15319b7b34a804d2e 100644 GIT binary patch literal 2847 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 delta 572 zcmV-C0>k~E7Rm&W8Gi!+006nq0-pc?0H{z*R7L;)|5U~JDYo_jSDXF*|5nEMy6F5^ z$M}8I`uzU?*Yf=uXr;5|{0m;6_Wb|A>ik^D_|)+I$?g3CSDK^3+eX0mD!2CP`2NN0 z{dLg!a?km&%iyTt`yiax0acdp`~T(l{$a`ZF1YpsRg(cvjDG_-U$Er-fz#Bw>2W$eUI#iU z)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G!hkE!s;%oku3;IwG3U^2k zw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn_j%}l|2+O?a>_7qq7W zmx(qtA2nV^tZlLpy_#$U%ZNx5;$`0L&dZ!@e7rFXPGAOup%q`|03hpdtXsPP0000< KMNUMnLSTaTGc_^* diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec303439225b78712f49115768196d8d76f6790..51609ede9bc749633709cf6daa12ccf10a798de6 100644 GIT binary patch literal 5030 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 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..6a9198fc4b21dab8682c2cf9bdcda14d2e32219d GIT binary patch literal 1469 zcmV;u1w#6XP)`9(woW@$l#Sfz!&@B5x}&r)t;mPI!ksk)@U&nN%<@;~SN9}(Dd zNyM`jcsOCLUH4*+)6!Bu-*_SwG2FC4*X$z-yOj0}T5&gF979~p5t96ZlAG&UQJ z#$=PxX1nL{_zVUej^k>TN`gzAPNyIUB*`EFTfc72&%Yc+2u-{e$8p`=-Fm$~D{F3c z?gE66%ju*kTB%aIeS|t%>GKgB#~KWAIF3h1rCP0aY|P{J`fi%dcDr4p(RjVyiPsWw zI2=_~)l(7@vgT#Q#l>l3VjvP|h5-<0R4STAwtEhpE*3`!LZP9tslB5^E|~@XDXr;QmKUHIFHw-QL6wAM@L<1mGaQH-&?JN zuW#P;&X%`S(b0k+upB#LAN}gA<+{1VCU7VHk&PL-h7o>6{V0vC?LOIqf${6 zU0Yjku?+0q_0jrut1yIleLj*T5kip>()ZunCY4I->Kj9%B{FjVetqqQ?A(Q#7;Th7 zfngX)(~#r-xN;>YCwtB6VgR7u?`K&SjUxyG5BSo}8#}Lc+3k){X$3q;@=|MS{_+9@ z1OY;X(5-<12;th*D{G%^yy zumS5pQc@zzu{1@az-UPjBqLT-iek)W>!Siruw6RL%b8-F(dfaAwcY)VXa_zPPP65 XuTTS-I)cUd00000NkvXXu0mjfaf-^S literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..1f91c582e7d9c8203bc00419fd3df543af392665 GIT binary patch literal 3918 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 E19r)aj{pDw literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..1f795ffa955aea198ccc1c8beb49afd68cda3293 GIT binary patch literal 1783 zcmV4nzm}If+2C! z5L^gK!X=QFptcFIiQ>j4C>6DFZ3j%iP;9R+*l}XB*ICbHZ?khbr`nw=YO8>nlwB8T zfAcaAbI$y9=9~YV`4r&(K{4L~Ux1uzX2`i_hMa3=$hl^QoNH#d&s~*DrNiN17@DH! z`B1tepW~WLCP|WX*H*7xvs$m$k>PM4NREtnu3o)HlH`0y{*%wIfoL?^dG37Yx$`dP z(#`dos;eH`Qoqq=wFLsf-v0iJJ-xlXeH_Q#X;O>D5>Jy2*UuQAxYcs-u>DW=ty{iX zU0rE38aHmJdvfQFcp}l&)!qK*j_!*+qA32yD3T<1?${mTeuqIYfrT z*>jcg`TZ{)e%0w*y6efE@pycC`jg_~VvE`Q)OQ;jcRlgfu8Xh#{*9rb;m@vbIr!3n z{m;LC;>dyD9`5by&#J4;#Kf(pea&unO~ckLk!Vz_)jY8HL9JHfuC1=Esp{(PdF9Bl z>FG}Z0HP=!I{fmHmk%}l>KCnN-#PiaHz0&rbp-$*gu1(X2CrS;x#K&_mw$DT8noN( z#YIH`fU3$>Wo2LM=seff_U_E=?5Cg3ct%GbE-l^qt<843egA=j$z(FCt}+bE9((QN ziWQG;|Ms@qw{MdqxwNE2sZ{0@ME$0X6)V5~(bd6;TayI^1;Jp@Y&MsdulRLy)82j0 zN2AefyUJXAx!eT*XJ{tOs=IR z&Cl-Hv-dfcjm(290Dz|HS6)3sGRx1ENqty@u5uTglI1WM>2n5t> zO<|#d9*?K6u&}DCa>Is=f+U85LBBr` zkHxGuJHnh{*dRtrD z><)Xa+wHL1t?3#?2ylYH(~Qxe&%^Su92*4c881;G_$7wo(5YT8;i3E4__=%B`QLEMR(`TNp zsi`)b7Em?0It*a%pZ{QNY`mtr>ctnDaUAD)o@H4<5D-EjT{_P^{lf?w8MyLMHeA!m zC0)MsASe3?oUBoE{KCNt8rU`2P2v z8oYi(5QJ>F%6LYG)f$b{>54{TTCEx(==1twvDo^$$IYe%k|Z$;_-MoWswA>Xe7#U z90KVhT?}6-E*u!R9xh${z!G&Tp+NtKmseG+Os#49 zc}tQsM=_$9vV;ioyodoXHa4DZR~ep94&AshIX=F`>C$MmqoZS<(edKqVu%pW^Xbi3 z>as*K8I46n34S~|MbmfdNMyY4l1L>;qNDEfKH>i@IoHgPbIlAn*UXS}%?vr$%#d@<4EK}k ZKLDbXkcDE{$yWdX002ovPDHLkV1m$Bh=l+E literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..5237cca93fe046d4da1406404dd833a63e0391df GIT binary patch literal 4682 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec303439225b78712f49115768196d8d76f6790..51609ede9bc749633709cf6daa12ccf10a798de6 100644 GIT binary patch literal 5030 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 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea27c705180eb716271f41b582e76dcbd90..fccd50b32d59f990af013caca02819dc2978ed88 100644 GIT binary patch literal 8980 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 HG5h>K3lBtM delta 1668 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 UtgQ6nIsgCw07*qoM6N<$g6-t6Gynhq literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a5a7044c97f7f45e3178292d02e3b28932bc8346 GIT binary patch literal 6488 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 literal 0 HcmV?d00001 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32ae7d989f82d5e46a60405adcc8279e8001..3aa01385b361441e1c66554f3c4e73613f1324dc 100644 GIT binary patch literal 2671 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 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba09064923c5daf2d37e7c3c836ccdd794b..2427cfc0f607d0213ce74421507268a706626567 100644 GIT binary patch literal 6978 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 diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf12aa4d28f374bb26596605a46dcbb3e7c8..a7013536eef772ad5642f26fc1dbe9e76ddc8455 100644 GIT binary patch literal 8034 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 delta 1410 zcmV-|1%3MBK8g#F8Gi!+002YCyxsr+0P|2xR7L;)|5U~JDYo_jSDRDB`2GI>{Qds= z{r_0T`1}6fwc-8!#-TGX}_?g)CZq4{k!uZ_g@DrQdoW0kI zu+W69&uN^)W`CK&06mMNcYMVF00dG=L_t(|+U?wHQxh>12H+Dm+1+fh+IF>G0SjJM zkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJTkdTm&kdTm&kdTm&kdP`e zsgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>VI$fQI%^ugM`#6By?GeadWcu z0gy9!D`m!H>Bd!JW(@avE8`|5XX(0PN}!8K>`dkavs;rHL+wy96QGNT=S@#7%xtlm zIW!++@*2zm-Py#Zr`DzqsLm!b{iskFNULSqE9A>SqHem>o31A%XL>S_5?=;V_i_y+ z(xxXhnt#r-l1Y8_*h`r?8Tr|)(RAiO)4jQR`13X0mx07C&p@KBP_2s``KEhv^|*8c z$$_T(v6^1Ig=#R}sE{vjA?ErGDZGUsyoJuWdJMc7Nb1^KF)-u<7q zPy$=;)0>vuWuK2hQhswLf!9yg`88u&eBbR8uhod?Nw09AXH}-#qOLLxeT2%C;R)QQ$Za#qp~cM&YVmS4i-*Fpd!cC zBXc?(4wcg>sHmXGd^VdE<5QX{Kyz$;$sCPl(_*-P2Iw?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF34$0Z;QO!J zOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUCUoZo%k(yku QW&i*H07*qoM6N<$f)Mr1R{#J2 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4d48295..bac0a92 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -14,6 +14,21 @@ 6.0 CFBundleName Komet + CFBundleIcons + + CFBundleAlternateIcons + + MinimalIcon + + CFBundleIconFiles + + MinimalIcon + + UIPrerenderedIcon + + + + CFBundlePackageType APPL CFBundleShortVersionString diff --git a/ios/Runner/MinimalIcon@2x.png b/ios/Runner/MinimalIcon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9f0435a9ed4a68b15d059ad9ccf961a82afdf52e GIT binary patch literal 5103 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 current = ValueNotifier( + AppIcon.defaultIcon, + ); + + static bool get isSupported => Platform.isAndroid || Platform.isIOS; + + static Future load() async { + if (!isSupported) return; + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getString(prefKey); + current.value = _parse(id); + } + + static Future apply(AppIcon icon) async { + if (!isSupported) return; + if (current.value == icon) return; + await FlutterDynamicIconPlus.setAlternateIconName( + iconName: icon.platformName, + ); + current.value = icon; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(prefKey, icon.id); + } + + static AppIcon _parse(String? val) { + for (final icon in AppIcon.values) { + if (icon.id == val) return icon; + } + return AppIcon.defaultIcon; + } +} diff --git a/lib/frontend/screens/profile/app_icon_screen.dart b/lib/frontend/screens/profile/app_icon_screen.dart new file mode 100644 index 0000000..2f4e9d6 --- /dev/null +++ b/lib/frontend/screens/profile/app_icon_screen.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/config/app_icon.dart'; +import '../../../core/utils/haptics.dart'; +import '../../widgets/custom_notification.dart'; + +class AppIconScreen extends StatefulWidget { + const AppIconScreen({super.key}); + + @override + State createState() => _AppIconScreenState(); +} + +class _AppIconScreenState extends State { + @override + void initState() { + super.initState(); + AppIconConfig.load(); + } + + Future _select(AppIcon icon) async { + if (!AppIconConfig.isSupported) { + showCustomNotification( + context, + 'Смена иконки доступна только на Android и iOS', + ); + return; + } + if (AppIconConfig.current.value == icon) return; + Haptics.selection(); + try { + await AppIconConfig.apply(icon); + if (!mounted) return; + showCustomNotification(context, 'Иконка изменена на «${icon.title}»'); + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось сменить иконку: $e'); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBarM3E( + titleText: 'Иконка приложения', + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: [ + Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Внешний вид иконки', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + AppIconConfig.isSupported + ? 'Изменение применится сразу — приложение может ненадолго закрыться' + : 'Доступно только на Android и iOS', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.35, + ), + ), + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: AppIconConfig.current, + builder: (context, current, _) { + return Column( + children: [ + for (final icon in AppIcon.values) + _IconTile( + icon: icon, + selected: current == icon, + onTap: () => _select(icon), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _IconTile extends StatelessWidget { + final AppIcon icon; + final bool selected; + final VoidCallback onTap; + + const _IconTile({ + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.asset( + icon.previewAsset, + width: 56, + height: 56, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + icon.title, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + Icon( + selected + ? Symbols.radio_button_checked + : Symbols.radio_button_unchecked, + color: selected ? cs.primary : cs.outline, + size: 22, + fill: selected ? 1 : 0, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/customization_screen.dart b/lib/frontend/screens/profile/customization_screen.dart index aca2378..f2d2122 100644 --- a/lib/frontend/screens/profile/customization_screen.dart +++ b/lib/frontend/screens/profile/customization_screen.dart @@ -3,6 +3,7 @@ import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/utils/haptics.dart'; +import 'app_icon_screen.dart'; import 'appearance_screen.dart'; import 'font_settings_screen.dart'; import 'message_actions_screen.dart'; @@ -50,6 +51,12 @@ class CustomizationScreen extends StatelessWidget { subtitle: 'Радиальное или список — для долгого нажатия на сообщение', builder: _buildMessageActions, ), + _CustomizationCategory( + icon: Symbols.apps, + title: 'Иконка приложения', + subtitle: 'Default или Minimal — иконка на главном экране', + builder: _buildAppIcon, + ), ]; static Widget _buildAppearance(BuildContext context) => @@ -64,6 +71,8 @@ class CustomizationScreen extends StatelessWidget { static Widget _buildMessageActions(BuildContext context) => const MessageActionsScreen(); + static Widget _buildAppIcon(BuildContext context) => const AppIconScreen(); + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index a2ec33f..96d3fee 100644 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" + "info": { + "version": 1, + "author": "xcode" }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d9a33e198f5747104729e1fcef999772a5..a8c3408c8f984a1146b0eab5fd807cc2557bead0 100644 GIT binary patch literal 171671 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%0AN+D`kEK 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ⅅ8MglG&ER{i5lN315cO?EdHNLrg? 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 z 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 Pe7HEdAEg|=c=LY&YVNkY diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eba55c6dabc3aac36f33d859266c18fa0d0..2af46a1390a42635f442de23eda1809d3d0002a1 100644 GIT binary patch literal 5789 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 ZfV4D|e?qwE*+1+I(A6~3s8n-6{tq*D!b$)D literal 5680 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 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa40fb3d1e0710331a48de5d256da3f275d..9f4a44010d1c946fcec3c5ec835507b99079a8c0 100644 GIT binary patch delta 363 zcmV-x0hIoT1cd{TBYy!*Nkl1j#uF zslkZ;LqQr!tql=HTjWQzNew}$gW4deG#DD1ikcr-Xb3_07TVAaXF2d(a2_~P;GI5_ z_A~sqfLtb{P%QQiNC%~umKM?L=ZHohaJ#*2P^;09NG5*Iz2V0=Zm{4cj`~J9cWdnmEu(rBWeN zjBtB*kA8Lr<-{oYd=Z0To?tLUv)L2}QmGV~Oojo8gdss79Emce)v)ih6OKf%uGuJ; z%iV!ijYfk|AY{ncxRR%29E;gRBAK9GZ=lmn69|OFfzN%s;<-3S5QgzyULyzs&*>Lp zu}ASIeC@4LX&=X)i%GSbOgc@eRQf&8skQw?JpS$eAN_@1`v5b7g4g6zf~5ce002ov JPDHLkV1nxWsAB*C delta 495 zcmZo=?qHdqT+hH*9OUlAu+~e(85nZWksGYjv*F;$q5St43u8~|Nno#>V);&M{-U) z@^};P@J-aM-tAbjN0NYAz-Jo;H8C9rLxvTnh322DckmQ&W4@-~JY#{{%LOwyp4R;O zI)CBgQ}S&`CfiGf-TT|Uj%5vd8iVz&H`DL`mwd)(diU42=?5ccv?@kSl#J!n_Gn;@ z6*=<%+w8`~Luc!GzBkU2ZH+9?PBo;7b^@SW;E1AO8 z#+V-WKfU6s>Dv@!r=R{ z__oLK|BWI(7#mldPAccr5%_=D<|tdpbG5TaHoSXh-}Y3$&iGH||EY?;FYTGt+oBb% z64)AoB)B@RC8Tvc{c~tTM)lRSwjXW0Jn{SgyG&#+YH;lJU=P2TI!o$9u ziH9Hi^MneQEz)+p%IuT+`C^ci-8-cjnJ;E?PO)*+@=_>>_=pe?5Gc|=#FZf+Ai+yW2ml=TN7uQ`90Ed!Kw4Zx)idkD z55ZUUVDZD{@~0@2^p6!xOn?udATRH{T^UEXeB0j6?)5dm;MGm(b(frh{9wp%N!E+2{|TC1}=Q#buP+85s&&0rB2#Sk~xqY7iebPpz9wU;&v8_KbkFxwY;j&yftGhwG@}lZ$YOHvbx~-Tb z-?ReF##0KLnjTy~TcAXO3YD;ca8w_gevblO2Z!zh2T9C>$Fmd(a|N(82*!1|1+3YG zuIN#cbr#e1UoKXDEv2WAOiqfOEmdLUD;97D7NN5|bwJ{>S(5s{^Tuf(hVZx6kq%fR_jc6_G~G7CzAJ$4wo+4czvCHzc-`y zUA`26gM*{=RSE|n&yzt2X&Fx-^xFB)mjdUrUOII<%Vo-40D|Taucf~gB6Bpq%o3gk zc9h_Ft{7=fyVxmENhqZc@r55ds-QLx!q5A0$fNt#?t=^QD>jpEbn9h6&vWo0u+C)o z<}yg8GSIMK6cZSs_DAEk%4WH`#rX69SI`$wkiaYW58(@WJVww-ugLSPbESSK{^;oF z$n><7ygTYCImQ$di_uDxhB75vtt~;&G#}0p3L!9e^F@V_*A+nG%Rr_v*g>878#bdc?Og7m;c>k*4HGxI9RM>8NT3 z92CdqcBnmQV0v<$FCDo^i~9Dghb}J@h!y2EExjYkh`lo{Mut@PgGsxzxvvW^40i;v=m5)a6wk9f$?WBEkTUc{?2jTWjXuFanyMtjTPFV`yCaP6NAn3dJsp; z!fDN}*=Q~p={gLaxUXQ4BnLE(`5hvI3eu8L&=)sO=!09|`-IBZ@5VHmfD2LDC{rca zb!?53UyQnt`gdyTUSXV|sEjpzo7+Vs>IGk|lP6g)4t|<`;N+*gTBr+rP7vhL-SCFT zJ2nyyQ(ttmxzV8xyOOfDcZNTb0cuUx3q97(w0$RDfcG&Zjn|LQaMiDosoy0`c5#?WKj3s4YOs@Sv3X{%EtBS?`;Q={* z#7%c54=mO>6FOP2?ZV=c9mOPZ`t8aCX!@-%@F?}A~15H zhUq!sO0!`lOs2*LOh^Pm>1)s|Jh5r4|FQL{);rUyX>*v z+Y05}u2H4!=IxI`(-Xzq6}(?W`L_M~8M0p+LeT9+G-SYn>apQMqF(dy;wCc?i|0^i0zx203sa4SW-gK6=!bNG{c$GeWWlk#&6{RoWf9vW;%ViR^01#sA_lf zArqje@JL}T~}>U_e%n7cb!-(JwXst0(@Is)gZ;Sl&N)cCJGE5jv%rlX!x4gJhFOB8+(c;u{2J)#hJ*l7f ziwIN!3WKlCNj%S|%Q(ZA=;o_Qg6jTsztW@(?#OT zZoE#z+1-N%pEsn4NZzcYvu+ke&3*6P-S9QttVD7AFSyO7NEzRPA&}44Td}&^&-CvE z_K+vLEd&zW7v+t}H9iz~0N&~24aJ4)Q=o>8OCS|~yJaa+Vb zZ5W&J6U(~W^rcrxP@a`2V4FLS2dcZvL_jhYKjKe`yo9+rFU`$L9vvkhjN8ewT(Y?K zt2qS?6%iiv;Ix3xgSez5?`ammwt|qG_0cC<5C+#wf-7$O5W!Z0d;hDh?=01m?U{PD z@kAC+l-_kH42Q(~Ta&>a+T&K(#NGE5vM$B0?TMum&+QMNtJ5=w-C3#6U#He5sg~C0 zP~4E%69&Ly*%ag5U|Gj&iM@ox)xdrzHg6*Ac(NoV=WrnH@c2Hq4LL0%qxc_nreEQ* z*eNDdKVk&9uWq0fOPP~UjgJ=#8T@b044kXClQj&$pQL*wFS6fmt!XF6=d%Bz`5h`f z{lwR<4Y6;tr%0B?cEoV~Iycc`5#W1`G`v8IT#F0{3mNj^+yF)8O>}SEI1DR+tKL&)#=IiAvm34)~g|emoe3 zQ|R~laE@WgnvjB%l+Zp6-K;kXNgzanUg(BE2Azrn`7^^4fm%Kb_$Z_qPLtysNJF&9 zrSG{TvF|Z;kYHEEuHCMN+9tT>M?e)K*5Z8n<&N$=rv0)No{B0)=baWE>S=7H%Mae) z&m&!2Da%mIA@k3v8Wo28|*dw@KuD8wfjIOB)MsaNky58?RR z@iWRMKSZzSFHip^1)Rdr_zB$aJc2d|p0oK1+u9P19c!tNJy04@xsu+lL1x2C8BUlCRhKa z@tPsV(V#g^uG^tHtNKGomQO3)yW%S=YpaJoZUyAtLvOUZ9pMGLW(+OVs&x0xBm7}N zU1etxfNabCaR5exQ5gO3CJ*PWo-RW_b25DCYmN3KZ8w^TI=%N@q}do4#_weaH#m;{ ziJR6#E97~2p7#7a_VT+0qeG&wa}#aQv-pD{Tr~prVt2Qy7-yGr9AO|DpElCW~zNN*UeN)(4)OjO(SeZ?QO4d`9lkM zyAB7Ty=xsJXPJZdq!^*q_dnoZ)ak<9YQ2`+-#7I+5S@4Y3#FI@g~v&SP=0px?se<9 zq-EhHEh?}zGu}pUF?`ra&W-_pKxEo9B`zZ)d%o6;W01>_u{RXkw{|&oU_|vfy5d5P zhCiRP)rut~gG1OZiA+}-2RN_Q{M0{M}AkyT$C3=j_BEZ9?F@xjPjhAS|=>9s^rmwVpvrk zq{VwcmzSN#$Bme^0KX%;aXCO2elP?+z_orljg^Q&c~l=t;p&FgvH{z+Sw2)jaFMmzXw}~dd#5RfyE-$0 znLn8nch*r08R{WIDzWuXlXja@16O#~$$c67p{J;nF8bk|E0OmzXAx$A%jwbsT2p-Ipgr$(={<%Mr{L7E*;fVUMz0P!kjTuDGx>a)9aKw>0a=ZuRwL(?WP)6nGb1 zFPHgKm(`4o+Q6GGrCK)v_V&nR{E%tb5-U7-waKo~97eqJh!PFDW5^E8*~O)E6Ofb8 zgK5skYJm|`@4lPb{Ei&pdsU-ZZz}!%whKEf4A}l2*zpVg^G+k0Bmk1(Oj#w7ACm)AZ>&Zw?A6tEf1P?LkP@E#k$EQ97wuFMlOH<{8Cu{G3FACd3xh*?g1m z;7#m*j?nphq~?DZ_&$+w?+*RR5r{wroJVH!)F!V7CAmEfaaBv%4?hf-!5X!IG6;`f zN5t}SpvKVdE__P3LPi$w(>kFQB^~^$Nu;J_UopX{<*bU=WY-YNd8SK@uhC%(wLc7H zCtyB8!tW-t_{Vz&zuCX0I+tbn)S@iUQc5Ao(NJmHqW+YkBJ{4$t*wL0%ve)Ocr#pQb)n?N;|<~5cI*0viS4kIV`s?H7Rd6fhImFX>U?8s{fHJapJoQ@Lq-rwE zAt-?knGK=myX(u3`b~AKs@euinr7BB=YlTOcfvNzUYgE~SW|C$5FH5(@@aZrmayif zzT^6P^qZ&l8L^9JYM@ydcVo+82Ezx1@}yAcf+ zDBHh1L07{94#l{Vg9yM5weCddD*#PIW{y zZZ=58!*;88Tx$JapuHLpGw8*s?Gyu*#^~*-uIx|MAeMr_hH&@0A=qvNpti&3!=sc_ zzney($`Swc_Bx7KLa=x~HIR1tEb2y4*_t-;l0KZ^Pk>_t9-<|4kFUH8dJ2ian2eC! zi2vj-RJ!kXHt8}_4UN*2!aQM>oLV3bE!3a_)A5hUm?5kftpQ8WSer{~7&@_rZM16DbUpaNu{j!BK~BWAzYw^`09b za5;`=rOl0jKx_HibGXEAC`f^_t;opd&x{outI#*>+2&u=n(%ysB-ACso%^@ zT0dTFX1nZ4Ji6skPwTU+xYzZ4kJ0JJ#+4Kmx4j_E>69+H^6o9Xl_Chjh7o|AIW~co z>8qG}Z6$j>zuvQG7ivywzVv7uJx(*skO6#{6L8%@>oH)yqCl^`sUzap6LH9up{kq z5nm`#Te~Jjr^m|xbs}^(=XY-*8?DPg-6I4m>Ff74N8hR1 z`xjJAfFw(3S&kbQia>!b@d&kC0ZO zFg%6+37H^E_?KSSQ95`m-R$5%my?p*aot_?@p)(akmM8H3xCRSp3J ztb&tRnLy1Rv3ld0LxUrGYV%fm7y>Ta{U$@_gUNF71p;&>im}O2F@0%fQ=b>;aneX^ zI;NRo8yqn0xV+(O8Xrtd2(@>Ozq&FxmoJOT`S@i9Jt-rDHZs!vhT9nv>zd2T_Y3>}VFu z_k-y#m@~U-h)7cc9uU;J&Ip?sbPX_F$l!7V%2jzzocPpbnTEVI_fE1n5x29qyg6sr z5WjvlzWSuTc-&F`lYN$#B1Xq9uGuACUb$>c_-&zyyIezCJ9eyMdWNH$ecZ(;nsL4p z+BL9YXYR8R1usHYwgsWAX=)LJ8`a`^G%1fK*r>35W`U^(K=pjm0AtN!6E;WG2=K<7Pq4tO1{Bc9|e+p46epd=8ToywJ8&J7!aWBYaAv?1FtQU<) zQbO(P@PIh7E4i{jE})MeQUR7$%Y2RO_wU~~x{%%5!TNe=Bfz!ZctbYTkpHIL+wbD{-_C$F1CmpX?_!Aqy%akL%)hik!%8ZW+_ zf^`n*Xadg>MilETXtM!c<7f8R4&-|dY%Z9`P=R1OPG`5+-SF6w$n6tFCgS~)&3Eui z9a@_#?C4&iki#;03F-vzWTeElomHu{vNB87na=~%m(%Iv#fURFKFl7;jFiLjvqB&k z%=-2sG^jJ_&@caE*rFIPX;qEcbh3C!AD6bGTzM!U-#4`=lw0Gh{A?C;vIBy)gUkl5 zwIzPMK#d8+N6xs%B&&uR@eP1@6Mz>gjHk_}9c<_~;RE%39p;nB7^3;u`a+@;5pi@B zY%Q*Hl;BJf5OAOeFA|8}LkH7ZZzJ>^?C{+qw}V!M&i9}0U( zoE5}k#`wI0biYZ({x$e;rM7fcmd;f9cwD;hzI#pO?y+F7a3`b;EZa6cQrl9SU!y&_ z7JEUcEiN)XuZO(IB@fmC^{MI>PAGB$a?Dfw{iM0GTMtDgt%$ZC z!F(^=C#5k4*)P0HoDw`u7oOJ-vp3GRYub~oXuQu_n3WrTE%yO!aiF64Epno6lWRY1 z`H33SLA`)eh$5CBK^+P4v!b@v*shrkT4oRm7|sztwk{7i3E|&gACFl66P!>!h%FJ( zp~@^2J+D@6YFrpVc_KlIGk<6v4>Qep(z0DjEJKksv$xkHGZT7wLaR60D)u$M7>!pQ zoE?=%^|BlHjNHvvTw4Zj7^N(g6jPyMD%C*eTF18q{JcippuH6N3gw5Af{&kJ_!dsk z#RE`1!sxK?b__2?O-N*ozzVl-5{jT9ZAFl2Gbf@yHZ9?FmJ=qXgW#_LI>UZPi8w^U z#>O`84@L3qCdCkiK!M>6v81>RlVx*44S@>@(WNN0;v&YwHR;4H3Z_u_HD=GtZvT%S zR5GBU%wM7*xk_G%9aUzkB|h#t@`E-?zF&$0V<9=U{j8(V4MZx>t2de)*Xks6cK^)V zmxX?Z6cOqE#VM_Spa4JQm?C#61EBi?yr2ePcI8$;XY7~Bh=>w<%=`%N#ATi;Ju{vQ zrw;#h?DOZ3jpq<)0wWXIzjg-_X4*xQNmv79G9aAfXaeR$COgg_LG2JSvCw>tOX4vm zcP_|YuDaz1W@L#Darep!W+#X;`!v=K%bJtY>exT%7i%p(aGi<7qrgS}PhO6hAS^Ha zPk&#F?WT*hS!pjQaT!8h#YzW)4PeDMiy$p!j>A<2Ow**K!fFtO)Y$U?S7z|t-U-iG zth(XF>ffikc?GYr+8hDer)g3c#|%a1eZXeUJ@!)NX#a>3MZ#i9O!IkvWzcJJ?Z=Er ze@z3|D`dw%Ki3NSW<85#G!nt!iHS>(rqL;Pg zqOMb1sN_SojG8{01ys+IT=#$y4>vf1+C;MNfKPBH2u{*HQ4&O zs@*oo`0Phfioy+G5JZNQkoIl39BZp-K^~GzvYAnbYNsGeH7IhHj?3}N5rM6teqHNb zk|!Q3+PW7$m04``?LA)+(_&MP2(s1#tqNuu){Ka@=z1C&xPx$vv9Bb-jd0N`|9j0( zqw)>lAfG!`*ZF^|LPZt5n87G)qxYA4TsBu)i`7w(Qw}C%nK@{e3MUf z0-6MIUr3AS=5z6wU%B>-B==p*a!{*`lGdpQvADNm_7&uY>eR09U>KtmNXE9*v5vag zgC0Or@bB~TNixC)GcOC`0w(MJVU2v-3IYK6T~yJ3I~&ZqbxNTj6SJi)24Q?FmrzvM zbL@*|yK^NEi$26-v$lH@hYPM9+g_8&Gtv2m$1O8uQ=Y+pf_40C`NERS*cn{#b$5Ag zQhd|hc%!UP6wYp7@b&RlQGp#K6l5-9JBYd2MNL zoLyRx65vWpii^p3T4AI9l!+>8sYO)jH0PI<0T?v=2M`AHz(^}05P7$ONk_56fnGWwZlH4zuwv;hl9K-Elw43dri34t|KBis2SBV$+5VbO{jsK(OL=cnyMhmm%)31yjPd8+Q~ z`+N5{?cPuo{Wcnc1-vh^aq8quOhiTlVi1B=L=P7mM|*gI)&(_~3>Z`kOG7PLQf30) zRvUu0AR?CWG$ibAz1_J!ISro%*kFF|>iseREr~fB=%}fHS`mcRutGOGfD^Ms^;^P( zp0fV8VZ>O*#aeT(nuh$2(;i>s!}dwQ2a-d_2QLFnCSNc_2EhGdJ%a1uqQ5n0O6;;v zv`c{i6ECg$;4D)E<4WF8jJ1sIp3H1!m6nj>GglP4;%MG#otmP5e8~r{vhhh<@;vz- z*#Q_G2AUqRJkk5}?*%FO&DY`K)pKw1WYhAB8H$5y!*xE6<+*-*{g?tfmjbXw&o-PU zVJVM0XPS49sN1C4U0AJm>-5ST5S!_LC**m4O~Ych%1%7WAq9?DviO967E4xl5N%&; z5Ih+J`+O3D4Ro=}@3AmWg%gCjj-`v?Fb(>JdhM!f4+4i>Dj^Ok!j!$d#zq(|t!hb; zBO=d)F#CyFD#f1lOO+2%gUgBLtw92Zi6>>fIMbf3mqV`WLK1&8 zCKVMUE&J+zwhF*-kXLu=O0+me15uajc_ZGIQVBj8@S4{eQ2Hz1R40^e>n2Uh1O(q~Uv6&_RXEV8&0WrLsV2QftC;+MP zUEG3$4$f*{T^t3be32j^rdrtLoNG(fIUX)ZS&&pq4sI@IwBC$?#qVW;+3c+m^adtn zqg>##CL zau1+(#XY}hL)AY*gKFtt_TJz62M}-yYHK05wwxJ!I45EMt9S20H(vf(aJyciOGXn8 ziW0kMqbkfO)p5xY6%^+%CEnl2=^VgeX?TcCt}P2Z8!q#V4N1(0teTrs{seI108g8A zU$lJ?DtF=r9gnY!u%CV>t8I^3DFl=UZ|#q!?s|A^R^MQO{MPyMD8~+cAshl*FM-v6 z3M<6Rmt!l#lGAjOC+CW&Lk7sG?4N#b(V_j<0J9{9iBL@|DkbJj3YrvTVlYQVyK8GB*RK<=Z z3ir->n)Gh%2Ydj~+NKM5eDk>U!}!+jis|n%e8C8GMp0oF{JYD8DA>vL097#SnS^mh z=2<2qy$hkj5k28`p0_?tl8qZR$*jsXwh}`w354Zv=UO$3)Tk z5&Q@=izw<#i~pWv$L9yU!Bayt5g%N4+r<&U#|4h*V0M5%olS$D{CcObWGSzrVl}hD zl9RaCvSO~Ay+&!QS!Po8n{^AZ8ZuZU+{tP856XSJ`|MIasb>XyF5T!6q<4;jOw5Z+ zAG#eE9nG495p)a+Hpi7%$-1*@FYpOrVC9gu~lNaMbcMqH^doA$MDcFbCH$MmBs z{($x|)7J*R&eC@;?lqwgg$piMz-J1_{pRZfD8|R%g9c23?|p@3b2n)~f25pg6f0I- zfRo*qXh*o`hnQc{;>u6-E8zvp2^47p2J~zShRX>f7Vv2^X;PJdIHf$$%C6XpjZWIHPsymXvA(-!L9Yq6Y{&DzfwC0E zpn@^7CFL~h(*6yzsol7X#P?F>>$Q9d5(dla0;6@^gXyEBm?m+QyKB1Qh?3*YPAfJ_ zoJFHAf=SVhsm7c%&yQHgy6{*LcKKUc7FU8pTw7U6XaR$vztDx*VLQ`PnRWVD`3~SF z(y(Q4@~}*>m(RyFnH8QPyW1<<(T!}6XCowJ5Uuw-{y^@S$jdt?;V^v|M_f#QURr>A zAYNH1ji==9+7It|;&@c9Tgo}V?|F9%kEp^~2i`U_yMV~>rCI#nGa4n*1@(RIlQmnN z)%tY%OJKmxK=#IRcyXl|8y{maUa-`_ak#sEZNL8M(8I|Kanfa)vvWjyPipqozpugW zWNO;@ps!*v+ZiD)kNiiY=A(~Q@4L+VO^v}R9D`(Ge83c>>unP6NqMEzX1LdBrm_k9 zP}t5x=9q92U_w{;XAHyq^k7mflb$xR3kMa)Us(H>>(A}d=8xUqpYQl!It;RBv(x9Q zx1$x8)0k4bq)B`tf;?R(%*0W(;pfj70*alV(q6Fbd;2H@|KHQ%Rl4UEve-5;Y(8|>)~MmnjdfGH^ENq z|F< zBcRp1zVq&e+UGp9Z3){D-R)U+_qI5h1Vg~YkbFU9)NX{>8;X&_#B0;(JJR=W$Pcyq z<;IOVCaZU?pd3}ZIeJ!gIQKQAb`CIOI3K=e__%4Ts-DCB%=HoI#NV;!=>p|?-|Mt1 zDPt`fKt7F(^|l#wwEIepxky)DU%Zs*;#-p&r9ZDJT9KPnBgMphJr-%5gn^;**)X+@ z2auDdW`sdkJYQ?aApi?uHG!Mg+O52!M(*=g8%&I-2-@0ogqOEXBord%PV@}4Mn{3eod%96yF^wF2d;nZ1Ezh!6Y+Pi;RCiYxQ#-3$KF=o)-&AuY%A!oJ0 zYL?>d3xrn>n`NTcx(f&>->Kw{h?lTH89>> z;3q!dx}zL8-+FM4u%u@S)T&PBx3IQi#jPN?81qy*>6MceF)61*>~|<1ue}c;{5IS3 zwpBkOPKm&m1N;GWfqF+Sr_8nE$b)2zPC}Xnb>ZfxHqBTm#Uyf3mN2*aksZRW7t}ua za6uM#OSmXxzY zVE`G|iC?1_Fra;xQ)LsC^;aurs~CxlzMt6cT+p-)-_>`peZ~~I}HurTo z6XX97nijHQ`>LuAtm6fD)$3|WpCHsU=j92Pf9w|Ev(Z*xYiQKL;8@4z3l?M1pbiGe z*NjRhK;5D z|SicgKnrb=XTmYAi5VyHQM3jHROtHA^8cM?RSUUaq~669ue z@W=;ol(Ey`K4%2H?Zb&F7|Kg5#i%tk*Ogz3fdL4xsJ&4++Wz%Z`|FR+;ndUd7sk3bKyWXpV_4%XQ}ZYw@E0ZutZrKNcs;v zb*3j{cB{6*HYC-G^$6YwZZ zThf#xHi+o0RpA8Dm=co~%LyWfAJjYEN|GG#Um7_@MrHgGZL$SJ zAQ#mZeFfTdEyjW;&mKL9=JxFV8bv5LaR9%dV)CN~+$j0rX28%+SkqhbYS2H8<#ExO zJ)fj3mY}>m2q0zO-eU3ot5jU?F|qEYZM~P-%e?Zn<@R3ABf8mdUk#x?aZpwc7b3ni z=RaTY}dlzT(3D2X8+~fvjIA34+blJNP>EXpL4J1XA7Z)k%dxTjCcpymaGUd zTBUQj)U50Smh@l2Bnk{#qgPtSFV0GXF!m{aBrmV*j)n~6a>?oK`MO#x=IlC;9u@cg zv9k40G7!=XL&&Ldc!=l^s|4@9Gtzp;FJ6|j7&J5J4xos5B??g6SxeTszh2Wk!dS*B zzB*EdnD9miL`O(T6TVjoLKHnJm09w)6D^|w)f|d~3P^ifl3gO0?*Z{YN}EEdVbu0! zcG#-@O^b>hn~PGTT}5e`Y~ThYb-{$0zK z=u9e}vLEvOE7)g*`xq11x5Umaw~8xA$fk{U{g1;YAY2-N-I^V&JKt^N8Hvi?08?R> zdepw<4J#7w8+d3Vz8x4G{C%@BV0F^MXBFE0x~hf$9|e+(=IVdV^H}Ns8n&6Sw7ckf ziuhw>*a1G=nLS=+Ai~uxr_u|IGOgbsa8hJ&(Q)-H&x$^R?ej$er zMa&=9f&_f>r9>dLi;KOgTYOf}a(raHAnHs^Tx2r-k*TVyI?l}s1@{l^^h;?nt*~(n zUW};pkq|8pFWgG`)$8l^mx_~6oi&a-OrhOHxlefeJ6%IkLwd;J`GupQIURkp5+oEz z%CbchLFq5{%p+=sA0tL)2Nx8Of3#*Gg24#!2tDeUCLYAo;DQ+7g?Mh0c^pk}xA(?6p^m_5NVMg0-PAebDG=pBX* z+{V4a&b>eTZ-T=0au!Rk-Ce_Z3;oo1SYx!8D<(|-D0-q23$sV+kNVEJzuz+tZM%F9 zka!@!xYt{{RGQ6S@_` zyt@HhGC=em1!CIdR_X;Y>2MapV~8|^Y7_f%zHM=c6Pg5Pkghh`MRm4&_@d#&T_rGrzk<|AR!(14?q|<;Lyzs2;Mf`Hjx}ZH^}mQeQ3^{PE)A0*E+y zW^tkuCF3|eoHXEvu45Ge2w#PiRaf-2DJ&UVC}DWtp9JX^)4M8|HQSbzo~OkK*scB* z*tsR0L5wl1wAw7=fP^e<(V@_Nt$rsb8z0P3t<*a~PaXSE^O_#D zWH&2ud8LRCSxzh}$WCvc^e58*pw<2FSZ{9mXsqAm9(6p{Pv%#3;O)KOWWD|8#b)aD z>F*a86qT7rWmnUa!Z?hqFvH8uDyl~!^!Y+*zY>nRD2@pW6p8!r8cks+dn$&32c#BX zx3`^8NA6>_89TFSU_H3p44P9lyYIh1CLB?fqaA>|P=!wc47 zr__0Ea^7Hg_hWv=c4s5Nu=~^j;4^)4>3zq5cJKG%;(TdieY*Y+hJ$0%<|JU5D-l6M zP2KqRkm>%TU1$+(@|zTCVZK=|Jld3$yS&uhsT#1dl5>;L&(8FM&(vf&wqV*Xr%;Lk z9K(p<7>*uy_&9Hq-%sE8^|558a8}coj7Wv3EH5hhP2>pj&L+(T&Q%PPioZNXAz$Ll zVZnlDgi;c`o9NQw9Pv=GKKIOTQW#Qhx_DMFjD{b4eITbPrD*&5CC|;gL<;JHxA9Rm zFgMKWlDTJwENFcoYX?$uh*+#)Z)yTb7|%VK&39N6W>-HbdHW{QOoX&<9IaBZ(i}}H zXemE4Lay10?i))1Uc^3;t!8(ZzJK>U3OID>Ao1W3A|5W*qRz7jm(bBM#0fLIH#^U6 z|3EJA--6E3SnMY(Ip@%Ar;iyKHtmr(-8Fjp>-RG7r#gnE5~#|Fpvlta+ka|oQ9@Ir z6B&D+4OJM0B<+=Ujfti0l1p*OT-#`KruepmcZQSz{@X9)4C7ql8r*rW_#?mhjBB!oR3}x;TI(%y?YY8jyk4vL@*@`u;UvDV z^6}Q@RVIm5jH<>J$C5K@)}n(a0VBW+Ee+jC-9ap=%dS)R34*1`vKcSRFs?mG=2y{6N1N3s11 z7vl;MNtyjPoB9wdh>YVTRraaLAcQf&jijRvOyFZN=#OYKnZ{w(4$hXK(f6GLBPvo$ zRnq?Kk3;02>1=q<&(Fg@$>-D{i7=nsAkEXk?EuH|5SzD(%{d^YnOEeAOD^f2Ef-;u zRK{oYOfCmrf$lsNWUzIG(8+iDUiWT4bNKN#+T9Oc?;=oLD4QRUJqsPy7Zt*FE96e<7<3* zC^(X~`WB5a{T_+)@vPR?1vkW_ZWzw8zS`)fLk}g|ieb3CkUxm~-yVgwi_BdD(Sdoh zfz%(0W`+(+2c}M+%gru_ybs{>KwiN4MJ^N+3_0wl6iDw?R z!6PMxv*5`^TRZy-@PYkq9nb1t@6JTCE*Xaxm9=hrxi6%DZto3v*?Pkd_=F)F)?~xf zcpLOg7&}e=10LIW2@W>=pWUVZBiGqWx_^DsYd5@_$>$HAU%-Akx?Af)5xkm&|Gwvk z#p_UOHg8^O&|IyR^Fj?yb#ic~S!+%9CJw+RqI<^Ukjl!+J@X5>!MsnK23t$&cUs$( z4l8!fYJ;tF6a(<}HM1`qDr*GEvF&=E76sc2E~7SV())kuKrDOFr8`K18OIZGUS;NSy`PR z?DZXAC#!ws*4B@uppsH_E`l*DE}rD<`egghFG%qiB!aHt4uQ;QWDvhdd%*tzpuiFE gzsvvset7z1QZ^m@O6*<+o|cD@mXH^(7Bvj~Ka+21@&Et; literal 14142 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 QaR2}S07*qoM6N<$g6l-xM*si- diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e72c9d820600887813b3b98d0dd69c5d4e8..ac881d5919fc6b4ad011b7d54386533469116868 100644 GIT binary patch literal 54369 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 U@vnKkwE!|s1YrZKkHlX44+JNUNdN!< literal 36406 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 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632cfddf3d9dade342351e627a0a75609fb46..0447837fb5f9acd658fae3dc298af3e28e192cfa 100644 GIT binary patch delta 2281 zcmV)qq0ts15SQW{YbxjJR0N(`oMo0j@ z3Gj`O0DKeR8zBMsCcrmB0`N_MZ-fLG?T=AWQOM27LA)vsgStTs3~12P+Y6`DIdKff z8KVkdGMP|YSBK=JBrIOM2re+hwQw>h$~Q@9!V=eg=a9n_qnyW#xZHXJ=r-1rzs_1b;b>Lv7s^x(f31v3m7Nn2biq zWHKZtCm|<03)x@WfS;Ltlquy5~9oIL#j-Yu{A zzw5>e_gw`p*VdBzFM7NL%b!|?p`jskb#;>v@$vDPQ&fP$f?4S8?IYuk9se6_HrrkI zU?KLa*MEPDUE8;yAb$ql*s>ixJ-zo!Eg%G?46+^j@Sp!i&Be=jdeutIF3Lx9ODiI7 z{|D)5X;3H>m^4X^jT>IT^Xs0)(Xw)sl~>$VO;|!be6$?jS+^E%?ca;-dw!4V>Wjgk zH!6bK2Usi?96WRw)ioFK?Am8ANv*;spMDCJN`FN{2!%q(wA>uT#m3-UE1!bZYJ*rTg4trB3K10@h57U5 zKp~f7>-Jsa(rb)+E6hsUww-&i=G&|A$Ty~wH?UC2NKdDbU_!*j#X%yGKqL~8k?cF8 z$ppK@LBc)u=p%UTl}*^PbthadS5Wy)$bT&0bU1MMNEvQ*&cU+MCrAL+E2O2SLKY^Y zXH(%3;gCor(CZDb+wCL*du7!?p}5m$Y@Yu7+3m7=|) z11gmo(b3A0H8C;Kqi+3pWntxzhXU@%${ zsZfvzHk*T*r^LiWN`mybmgR=nSbu1BIyjxoE3GskEcmk{Xe z?4&CpApvve6(cz%1(TDKz>yJmyus=9z-2ZgN)Z9EPz;|>fS#TnsuHX^&^nGobaXT{ z8a-NCTk-L=Yv{by36t3zG_K>C1K6(rrQ$tgW@VwUs1S06g5sNj^D!ZOFn^dVh>Da$ zB9g%E7NDRh_lt{$n)?SW-!E8aaTmg|-41v!Fjn;s;SS96#UAy-~qtzlUEe)Gr z{RN8W7LDB3>-E9oaij4@6MuH>+(TXGSTSK$fc5>AA6>xh?%S9-Gaq3QGMXM);uEk; zZ!sfA83~b447bY#tse|U2rAok9ou&7 zrg42NnD8^fn;jj{X|OiaMu zJv$(iN@>SgZ@`TIVr60Fl6_`}Gu_>{(bnD(FdtqHMOleF^REvvf5AfdVq;);d!dwv zu{sKi%??F`40Ux^Y3+*}*FzZ@Nd~ft;vc2`Mq_vyb$i_4z+v6Gwb=a2UkA*Ew^Lyj z5>=HKu%vV;gkmwwCVv|gkr7ntj7Agm1_R`BIZEailYuU$lR8LlxF^8yiIUUhgxBLG z<5?fV4r5p)(P%UQ@tAM{Sj?-`YDlFLgvr9;^068(g3!y7p_M|QzrT;x=6!t{y)ut+ zI2>fSkMmIlVV>Y`nf-#WSAkH7)YOy*R{*w~tUJug%tBUn4u6DF2?QP=xwMx9xk&dq@6pk4=L#`1m+@I4?YI4|xNfBnbpCn=Kd`a>DELk>O0BFqs5{gZhBE z@cLwk#qei08h_Ez-iGY#T%=6NAVb7qVf6RbmNw`H2dPZ5G1-5F#tol7S#8b{!~KJk z*`yDDwzb2JH^d zNbI}ZP7FESBxu*I&XMp41P_D&ehk@cxb|^9czR-?h+kDown zY%Jp9RbmiJ#N_(g0nJ?=Ggdq_0{FrD0(J!p51jzPMo0j@3Gj`O0DKeR8zBMsCcrmB z0`N_MZ-fNkn*iSk3BWf2z7Z0DZvuQHBmmz8_{M($EDuSgIWyFM00000NkvXXu0mjf DQ*};X delta 2207 zcmV;Q2w?a05vmc8BYyw^b5ch_0Itp)=>Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031 z000^Q000001E2u_0{{R30RRC20H6W@1ONa40RR91K%fHv1ONa40RR91KmY&$07g+l zumAuE6iGxuRCodHTWf3-RTMruyW6FuQYeUM04eX6D5c0FCV&1TCZ-py|c5InY-=I zY-!`3OG*I1PedcfpaTWG zz}$w!e2{QT6j)uA1OU8-N{6BItSS$HvMSg(7bJx2Y=32dDp`!10~I{udIVa+2|G#V z0cEH-*RHY~<>3g?j?-cK^;&$8cpYTHEnP(3ixu>PseUTRQ<~jf%ed6Q17;aHDPh|Z z+@II^^V0LKW}5)w|dwIw_gRf zFMqt(ako~@r$m5lC959fGLHG5;r3$WrNJgK`drR2zZ`Bq_rA1%mrYB^t3n{*!QNze z^;M{I8oD7Lp^7cb+fR6L@YvzuR5#}? z#=HP7u8s)cnE$Rj=B?ab0O!v1*&ZX$r#f@YbLjj8?`i3ZwP3VkB!sEhD8YS}7Jq1z z7cjRFzn@CV5dp`pMDQjjHsOiR&7egYgXit7?Dn_dG+#7?=|p>M-Y_s5iTElq)ThyF z=cei9Q{?i9h3Uw-PD>dez5ZKhet=tPFB-(!?{0iIuf_XT*iMSn7ti=0Uo<^*%h2LXNy!0+zfA`9?*&!@XBwdb_G06#>FlRej|>cKdhBgR~P{WYHL3&1oj#B1njWD1HN+;vYVgcBpQu z2vF7Q(LHwT=3uM~8Mrym+v!t>D=Dj$KltcdNh<*XtPa~3X!OJ|%zuy&0a{%3GGwkD zKTeG`b+lyj0jjS1OMkYnepCdoosNY`*zmBIo*981BU%%U@~$z0V`OVtIbEx5pa|Tc zt|Lg#ZQf5OYMUMRD>Wdxm5SAqV2}3!ceE-Mvs!lAvqf?6tS zD1t*y5KBjx{~&;KB9p@;yjZ%A&Yr*OuvepcN(6Kn)m=*PK4I%T9~HOM)8+a`>dXyL z$mdN63+{@ot!nS0XE*Jkx~7Y6^lpnqH3zi2(X-qJAD}ioJ%5efer&7`l)>_->tVrZ zJHf7}ex3-E;6h6{S$!4H2gWGN~F(zYfI&I!<4C6Cj}86!!HVKso`!wlU}od~3= zLBi5c_EG(5M{~kuOY|sI5x`$yrcH9Pv#9jVMYMQQK1QD>Kqz`+<=I!K0Y6;MBrGf4 zOJ~kqN!6ZuyMKzdrww#G29;c2k7Pss?YK=Je|dmP%VM#v1II%QoLSKG-Ph@epI<&I zrZEo;mzznJpNvmr8m%6(C4e7>5#_iPSydcBWwii5)6QO12ymUPb`=Luxe`M%4`70h zLeu)GTpG!&M%#1X0kLzqMMp{xKt-O9mO@)C>h|{{UVp}ysK7G-B?0^Z8sOF7N$5Z# zfx$lo+fU-El%xc3Z8UTkm-)~N&i8X+w_gaG54vtARs}C~IkXDe-{d3=S_Q>kv*)6m z1XazTqw%=E5Xv7^@^^T7Q2wxr+Z2kVYrerj@E`hDauQ;_COrTE002ovPDHLkV1mj54(k8_ diff --git a/pubspec.lock b/pubspec.lock index d2ce143..77ba49f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -89,6 +97,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -294,6 +318,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_dynamic_icon_plus: + dependency: "direct main" + description: + name: flutter_dynamic_icon_plus + sha256: "7721ef2666691064dece879d968fa7e1c4d64aa743ea998e6b1e65a1b482f212" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -413,6 +453,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" intl: dependency: "direct main" description: @@ -421,6 +469,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -505,10 +561,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -537,10 +593,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -693,6 +749,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" progress_indicator_m3e: dependency: transitive description: @@ -902,10 +966,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 80f23de..e4bd3f8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: firebase_core: ^4.1.1 firebase_messaging: ^16.0.2 flutter_local_notifications: ^21.0.0 + flutter_dynamic_icon_plus: ^1.4.0 dev_dependencies: flutter_test: @@ -69,6 +70,23 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.14.4 + +flutter_launcher_icons: + image_path: "assets/komet_icon.png" + android: "ic_launcher" + ios: true + remove_alpha_ios: true + background_color_ios: "#000000" + adaptive_icon_background: "#000000" + adaptive_icon_foreground: "assets/komet.png" + windows: + generate: true + image_path: "assets/komet_icon.png" + icon_size: 256 + macos: + generate: true + image_path: "assets/komet_icon.png" # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -83,6 +101,8 @@ flutter: uses-material-design: true assets: - assets/komet.png + - assets/komet_icon.png + - assets/meteor_icon.png # To add assets to your application, add an assets section, like this: # assets: diff --git a/tool/make_icon_bg.dart b/tool/make_icon_bg.dart new file mode 100644 index 0000000..9798f41 --- /dev/null +++ b/tool/make_icon_bg.dart @@ -0,0 +1,20 @@ +import 'dart:io'; +import 'package:image/image.dart' as img; + +const _pairs = { + 'assets/komet.png': 'assets/komet_icon.png', + 'assets/meteor.png': 'assets/meteor_icon.png', +}; + +void main() { + for (final entry in _pairs.entries) { + final src = img.decodePng(File(entry.key).readAsBytesSync())!; + final size = src.width > src.height ? src.width : src.height; + final canvas = img.Image(width: size, height: size, numChannels: 4); + img.fill(canvas, color: img.ColorRgb8(0, 0, 0)); + img.compositeImage(canvas, src, + dstX: (size - src.width) ~/ 2, dstY: (size - src.height) ~/ 2); + File(entry.value).writeAsBytesSync(img.encodePng(canvas)); + stdout.writeln('wrote ${entry.value} (${size}x$size)'); + } +} diff --git a/tool/make_minimal_android.dart b/tool/make_minimal_android.dart new file mode 100644 index 0000000..eed4085 --- /dev/null +++ b/tool/make_minimal_android.dart @@ -0,0 +1,25 @@ +import 'dart:io'; +import 'package:image/image.dart' as img; + +const _densities = { + 'mdpi': 48, + 'hdpi': 72, + 'xhdpi': 96, + 'xxhdpi': 144, + 'xxxhdpi': 192, +}; + +void main() { + final src = img.decodePng(File('assets/meteor_icon.png').readAsBytesSync())!; + for (final entry in _densities.entries) { + final resized = img.copyResize(src, + width: entry.value, + height: entry.value, + interpolation: img.Interpolation.average); + final dir = + Directory('android/app/src/main/res/mipmap-${entry.key}'); + final file = File('${dir.path}/ic_launcher_minimal.png'); + file.writeAsBytesSync(img.encodePng(resized)); + stdout.writeln('wrote ${file.path} (${entry.value}x${entry.value})'); + } +} diff --git a/tool/make_minimal_ios.dart b/tool/make_minimal_ios.dart new file mode 100644 index 0000000..cfd7db2 --- /dev/null +++ b/tool/make_minimal_ios.dart @@ -0,0 +1,19 @@ +import 'dart:io'; +import 'package:image/image.dart' as img; + +const _sizes = { + 'ios/Runner/MinimalIcon@2x.png': 120, + 'ios/Runner/MinimalIcon@3x.png': 180, +}; + +void main() { + final src = img.decodePng(File('assets/meteor_icon.png').readAsBytesSync())!; + for (final entry in _sizes.entries) { + final resized = img.copyResize(src, + width: entry.value, + height: entry.value, + interpolation: img.Interpolation.cubic); + File(entry.key).writeAsBytesSync(img.encodePng(resized)); + stdout.writeln('wrote ${entry.key} (${entry.value}x${entry.value})'); + } +} diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20caf6370ebb9253ad831cc31de4a9c965f6..630f63f5d6e95fca967a5d7201bad3e73e17a99f 100644 GIT binary patch literal 16234 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 literal 33772 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 From eb159de0f45c7fe3b8f39a8e113ea6a0c86b3197 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 28 May 2026 05:13:14 +0000 Subject: [PATCH 166/323] =?UTF-8?q?fix:=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B8=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BD=D0=B0=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20MethodChannel=20=E2=80=94=20=D0=B1=D0=B5=D0=B7=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=20=D0=B8=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BE=D1=82=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20MainActivity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 13 ++++-- .../main/kotlin/ru/komet/app/MainActivity.kt | 45 +++++++++++++++++++ ios/Runner/AppDelegate.swift | 37 +++++++++++++++ lib/core/config/app_icon.dart | 16 ++++--- .../screens/profile/app_icon_screen.dart | 2 +- pubspec.lock | 8 ---- pubspec.yaml | 1 - 7 files changed, 101 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 83e4929..2735665 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,9 +9,6 @@ android:label="Komet" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> - + + - + + when (call.method) { + "setAppIcon" -> { + val name = call.argument("name") + if (name == null || !iconAliases.contains(name)) { + result.error("INVALID_ICON", "Unknown icon: $name", null) + return@setMethodCallHandler + } + try { + applyIcon(name) + result.success(null) + } catch (e: Exception) { + result.error("APPLY_FAILED", e.message, null) + } + } + else -> result.notImplemented() + } + } + MethodChannel( flutterEngine.dartExecutor.binaryMessenger, "ru.komet.app/upload_service", diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..948ca04 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -8,6 +8,43 @@ import UIKit didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + + let controller = window?.rootViewController as? FlutterViewController + if let messenger = controller?.binaryMessenger { + let channel = FlutterMethodChannel( + name: "ru.komet.app/app_icon", + binaryMessenger: messenger + ) + channel.setMethodCallHandler { (call, result) in + guard call.method == "setAppIcon" else { + result(FlutterMethodNotImplemented) + return + } + let args = call.arguments as? [String: Any] + let name = args?["name"] as? String + let iconName: String? = (name == "DefaultIcon") ? nil : name + if !UIApplication.shared.supportsAlternateIcons { + result(FlutterError( + code: "UNSUPPORTED", + message: "Alternate icons are not supported", + details: nil + )) + return + } + UIApplication.shared.setAlternateIconName(iconName) { error in + if let error = error { + result(FlutterError( + code: "APPLY_FAILED", + message: error.localizedDescription, + details: nil + )) + } else { + result(nil) + } + } + } + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } diff --git a/lib/core/config/app_icon.dart b/lib/core/config/app_icon.dart index 9c3ef8d..96c662d 100644 --- a/lib/core/config/app_icon.dart +++ b/lib/core/config/app_icon.dart @@ -1,23 +1,25 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:flutter_dynamic_icon_plus/flutter_dynamic_icon_plus.dart'; +import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; enum AppIcon { - defaultIcon('default', 'Default', 'assets/komet_icon.png', null), + defaultIcon('default', 'Default', 'assets/komet_icon.png', 'DefaultIcon'), minimal('minimal', 'Minimal', 'assets/meteor_icon.png', 'MinimalIcon'); final String id; final String title; final String previewAsset; - final String? platformName; + final String platformName; const AppIcon(this.id, this.title, this.previewAsset, this.platformName); } class AppIconConfig { static const prefKey = 'app_icon'; + static const _channel = MethodChannel('ru.komet.app/app_icon'); + static final ValueNotifier current = ValueNotifier( AppIcon.defaultIcon, ); @@ -34,12 +36,12 @@ class AppIconConfig { static Future apply(AppIcon icon) async { if (!isSupported) return; if (current.value == icon) return; - await FlutterDynamicIconPlus.setAlternateIconName( - iconName: icon.platformName, - ); - current.value = icon; final prefs = await SharedPreferences.getInstance(); await prefs.setString(prefKey, icon.id); + current.value = icon; + await _channel.invokeMethod('setAppIcon', { + 'name': icon.platformName, + }); } static AppIcon _parse(String? val) { diff --git a/lib/frontend/screens/profile/app_icon_screen.dart b/lib/frontend/screens/profile/app_icon_screen.dart index 2f4e9d6..4534725 100644 --- a/lib/frontend/screens/profile/app_icon_screen.dart +++ b/lib/frontend/screens/profile/app_icon_screen.dart @@ -74,7 +74,7 @@ class _AppIconScreenState extends State { const SizedBox(height: 4), Text( AppIconConfig.isSupported - ? 'Изменение применится сразу — приложение может ненадолго закрыться' + ? 'На Android приложение закроется — лаунчер подхватит новую иконку. На iOS — мгновенно с системным диалогом.' : 'Доступно только на Android и iOS', style: TextStyle( color: cs.onSurfaceVariant, diff --git a/pubspec.lock b/pubspec.lock index 77ba49f..f44d5e2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -318,14 +318,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" - flutter_dynamic_icon_plus: - dependency: "direct main" - description: - name: flutter_dynamic_icon_plus - sha256: "7721ef2666691064dece879d968fa7e1c4d64aa743ea998e6b1e65a1b482f212" - url: "https://pub.dev" - source: hosted - version: "1.4.0" flutter_launcher_icons: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index e4bd3f8..ebb0e75 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,7 +58,6 @@ dependencies: firebase_core: ^4.1.1 firebase_messaging: ^16.0.2 flutter_local_notifications: ^21.0.0 - flutter_dynamic_icon_plus: ^1.4.0 dev_dependencies: flutter_test: From 0bf8eb5b6566daa316e24f24ed3d53a2f1aaf2c8 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 28 May 2026 05:45:38 +0000 Subject: [PATCH 167/323] =?UTF-8?q?fix:=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D0=BD=D0=B0=D1=8F=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BB=D1=8F=20Minimal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ic_launcher_minimal_foreground.png | Bin 0 -> 9105 bytes .../ic_launcher_minimal_foreground.png | Bin 0 -> 4563 bytes .../ic_launcher_minimal_foreground.png | Bin 0 -> 15078 bytes .../ic_launcher_minimal_foreground.png | Bin 0 -> 30715 bytes .../ic_launcher_minimal_foreground.png | Bin 0 -> 50083 bytes .../mipmap-anydpi-v26/ic_launcher_minimal.xml | 9 ++++++ android/app/src/main/res/values/colors.xml | 1 + tool/make_minimal_adaptive.dart | 27 ++++++++++++++++++ 8 files changed, 37 insertions(+) create mode 100644 android/app/src/main/res/drawable-hdpi/ic_launcher_minimal_foreground.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_launcher_minimal_foreground.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_launcher_minimal_foreground.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_launcher_minimal_foreground.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_launcher_minimal_foreground.png create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimal.xml create mode 100644 tool/make_minimal_adaptive.dart diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_minimal_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_minimal_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..5349f078576e057d993fa2b4f598a204886062ae GIT binary patch literal 9105 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_minimal_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_minimal_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..15423c3605820299bf892111e1286c7dba482fad GIT binary patch literal 4563 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_minimal_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_minimal_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..74e210b8872ba22f5b46abcb7fe8e53c6c18cc22 GIT binary patch literal 30715 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 NNr}meRttX%_#axz0>l6S literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_minimal_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_minimal_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..9d6008b84e7a0df07439e4730c3e82de04454b67 GIT binary patch literal 50083 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimal.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimal.xml new file mode 100644 index 0000000..7389348 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_minimal.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index beab31f..7d5be24 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,4 +1,5 @@ #000000 + #000000 \ No newline at end of file diff --git a/tool/make_minimal_adaptive.dart b/tool/make_minimal_adaptive.dart new file mode 100644 index 0000000..040739f --- /dev/null +++ b/tool/make_minimal_adaptive.dart @@ -0,0 +1,27 @@ +import 'dart:io'; +import 'package:image/image.dart' as img; + +const _densities = { + 'mdpi': 108, + 'hdpi': 162, + 'xhdpi': 216, + 'xxhdpi': 324, + 'xxxhdpi': 432, +}; + +void main() { + final src = img.decodePng(File('assets/meteor.png').readAsBytesSync())!; + for (final entry in _densities.entries) { + final canvas = + img.Image(width: entry.value, height: entry.value, numChannels: 4); + final scaled = img.copyResize(src, + width: entry.value, + height: entry.value, + interpolation: img.Interpolation.cubic); + img.compositeImage(canvas, scaled); + final file = File( + 'android/app/src/main/res/drawable-${entry.key}/ic_launcher_minimal_foreground.png'); + file.writeAsBytesSync(img.encodePng(canvas)); + stdout.writeln('wrote ${file.path} (${entry.value}x${entry.value})'); + } +} From 7ce445930485f6c93f2c5a0493922b6dcd497093 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 28 May 2026 12:52:15 +0700 Subject: [PATCH 168/323] =?UTF-8?q?=D0=B6=D0=B5=D1=81=D1=82=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=87=D0=B8=D0=BD=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/rightward_drag_recognizer.dart | 81 +++++++ lib/frontend/widgets/swipe_route.dart | 222 +++++++++++++++++- lib/frontend/widgets/swipe_to_pop.dart | 83 +++---- 3 files changed, 337 insertions(+), 49 deletions(-) create mode 100644 lib/frontend/widgets/rightward_drag_recognizer.dart diff --git a/lib/frontend/widgets/rightward_drag_recognizer.dart b/lib/frontend/widgets/rightward_drag_recognizer.dart new file mode 100644 index 0000000..c63fcbb --- /dev/null +++ b/lib/frontend/widgets/rightward_drag_recognizer.dart @@ -0,0 +1,81 @@ +import 'package:flutter/gestures.dart'; + +class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { + RightwardDragRecognizer({super.debugOwner}) { + onlyAcceptDragOnThreshold = true; + } + + static const double _kMinAcceptVelocity = 700.0; + static const double _kMinAcceptDistance = 20.0; + + final Map _initialPositions = {}; + final Map _velocityTrackers = {}; + final Map _currentDeltaX = {}; + + @override + void addAllowedPointer(PointerDownEvent event) { + _initialPositions[event.pointer] = event.position; + final tracker = VelocityTracker.withKind(event.kind); + tracker.addPosition(event.timeStamp, event.localPosition); + _velocityTrackers[event.pointer] = tracker; + super.addAllowedPointer(event); + } + + @override + void handleEvent(PointerEvent event) { + if (event is PointerMoveEvent) { + _velocityTrackers[event.pointer] + ?.addPosition(event.timeStamp, event.localPosition); + final initial = _initialPositions[event.pointer]; + if (initial != null) { + final dx = event.position.dx - initial.dx; + _currentDeltaX[event.pointer] = dx; + if (dx < -kTouchSlop) { + stopTrackingPointer(event.pointer); + _cleanup(event.pointer); + return; + } + } + } + super.handleEvent(event); + } + + @override + bool hasSufficientGlobalDistanceToAccept( + PointerDeviceKind pointerDeviceKind, + double? deviceTouchSlop, + ) { + if (!super.hasSufficientGlobalDistanceToAccept( + pointerDeviceKind, deviceTouchSlop)) { + return false; + } + double maxDx = 0; + for (final dx in _currentDeltaX.values) { + if (dx > maxDx) maxDx = dx; + } + if (maxDx < _kMinAcceptDistance) return false; + for (final tracker in _velocityTrackers.values) { + final vx = tracker.getVelocity().pixelsPerSecond.dx; + if (vx >= _kMinAcceptVelocity) return true; + } + return false; + } + + void _cleanup(int pointer) { + _initialPositions.remove(pointer); + _velocityTrackers.remove(pointer); + _currentDeltaX.remove(pointer); + } + + @override + void didStopTrackingLastPointer(int pointer) { + _cleanup(pointer); + super.didStopTrackingLastPointer(pointer); + } + + @override + void rejectGesture(int pointer) { + _cleanup(pointer); + super.rejectGesture(pointer); + } +} diff --git a/lib/frontend/widgets/swipe_route.dart b/lib/frontend/widgets/swipe_route.dart index 933ad50..2927d9f 100644 --- a/lib/frontend/widgets/swipe_route.dart +++ b/lib/frontend/widgets/swipe_route.dart @@ -1,12 +1,101 @@ +import 'dart:math' as math; +import 'dart:ui'; + import 'package:flutter/cupertino.dart'; -class SwipeRoute extends CupertinoPageRoute { +import 'rightward_drag_recognizer.dart'; + +class SwipeRoute extends PageRoute { SwipeRoute({ - required super.builder, + required this.builder, super.settings, - super.maintainState, super.fullscreenDialog, + this.maintainState = true, }); + + final WidgetBuilder builder; + + @override + final bool maintainState; + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 400); + + @override + Duration get reverseTransitionDuration => const Duration(milliseconds: 400); + + @override + bool canTransitionTo(TransitionRoute nextRoute) { + return nextRoute is SwipeRoute || nextRoute is CupertinoRouteTransitionMixin; + } + + @override + bool get popGestureInProgress => _gestureController != null; + + _SwipeBackController? _gestureController; + + @override + bool get popGestureEnabled { + if (isFirst) return false; + if (willHandlePopInternally) return false; + if (popDisposition == RoutePopDisposition.doNotPop) return false; + if (animation?.status != AnimationStatus.completed) return false; + if (secondaryAnimation?.status != AnimationStatus.dismissed) return false; + if (popGestureInProgress) return false; + return true; + } + + _SwipeBackController _startPopGesture() { + final gesture = _SwipeBackController( + navigator: navigator!, + controller: controller!, + ); + _gestureController = gesture; + gesture._onEnd = () { + if (_gestureController == gesture) { + _gestureController = null; + } + }; + return gesture; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: builder(context), + ); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return _SwipeBackGestureDetector( + enabledCallback: () => popGestureEnabled, + onStartPopGesture: _startPopGesture, + child: CupertinoPageTransition( + primaryRouteAnimation: animation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: popGestureInProgress, + child: child, + ), + ); + } } Future pushSwipeable( @@ -18,3 +107,130 @@ Future pushSwipeable( SwipeRoute(builder: builder, settings: settings), ); } + +class _SwipeBackGestureDetector extends StatefulWidget { + const _SwipeBackGestureDetector({ + required this.enabledCallback, + required this.onStartPopGesture, + required this.child, + }); + + final ValueGetter enabledCallback; + final ValueGetter<_SwipeBackController> onStartPopGesture; + final Widget child; + + @override + State<_SwipeBackGestureDetector> createState() => + _SwipeBackGestureDetectorState(); +} + +class _SwipeBackGestureDetectorState + extends State<_SwipeBackGestureDetector> { + _SwipeBackController? _backController; + double _width = 0; + + void _handleStart(DragStartDetails details) { + if (!widget.enabledCallback()) return; + _width = context.size?.width ?? MediaQuery.of(context).size.width; + if (_width <= 0) _width = 1.0; + _backController = widget.onStartPopGesture(); + } + + void _handleUpdate(DragUpdateDetails details) { + final delta = details.primaryDelta ?? 0.0; + _backController?.dragUpdate(delta / _width); + } + + void _handleEnd(DragEndDetails details) { + final velocity = details.velocity.pixelsPerSecond.dx / _width; + _backController?.dragEnd(velocity); + _backController = null; + } + + void _handleCancel() { + _backController?.dragEnd(0.0); + _backController = null; + } + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + RightwardDragRecognizer: + GestureRecognizerFactoryWithHandlers( + () => RightwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _handleStart + ..onUpdate = _handleUpdate + ..onEnd = _handleEnd + ..onCancel = _handleCancel; + }, + ), + }, + child: widget.child, + ); + } +} + +class _SwipeBackController { + _SwipeBackController({ + required this.navigator, + required this.controller, + }); + + final NavigatorState navigator; + final AnimationController controller; + VoidCallback? _onEnd; + + static const double _kMinFlingVelocity = 1.0; + + void dragUpdate(double delta) { + controller.value -= delta; + } + + void dragEnd(double velocity) { + const animationCurve = Curves.fastLinearToSlowEaseIn; + final bool animateForward; + + if (velocity.abs() >= _kMinFlingVelocity) { + animateForward = velocity <= 0; + } else { + animateForward = controller.value > 0.5; + } + + if (animateForward) { + final forwardMs = math.min( + lerpDouble(800, 0, controller.value)!.floor(), + 300, + ); + controller.animateTo( + 1.0, + duration: Duration(milliseconds: forwardMs), + curve: animationCurve, + ); + } else { + navigator.pop(); + if (controller.isAnimating) { + final backMs = lerpDouble(0, 800, controller.value)!.floor(); + controller.animateBack( + 0.0, + duration: Duration(milliseconds: backMs), + curve: animationCurve, + ); + } + } + + if (controller.isAnimating) { + late AnimationStatusListener statusCb; + statusCb = (status) { + _onEnd?.call(); + controller.removeStatusListener(statusCb); + }; + controller.addStatusListener(statusCb); + } else { + _onEnd?.call(); + } + } +} diff --git a/lib/frontend/widgets/swipe_to_pop.dart b/lib/frontend/widgets/swipe_to_pop.dart index 3c14093..18ce6ff 100644 --- a/lib/frontend/widgets/swipe_to_pop.dart +++ b/lib/frontend/widgets/swipe_to_pop.dart @@ -1,22 +1,19 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'rightward_drag_recognizer.dart'; + class SwipeToPop extends StatefulWidget { final Widget child; - final double edgeWidth; final double popThreshold; final double velocityThreshold; - final bool fullWidth; final bool enabled; final VoidCallback? onPop; const SwipeToPop({ super.key, required this.child, - this.edgeWidth = 28, this.popThreshold = 0.35, this.velocityThreshold = 700, - this.fullWidth = false, this.enabled = true, this.onPop, }); @@ -28,6 +25,7 @@ class SwipeToPop extends StatefulWidget { class _SwipeToPopState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; + double _width = 0; @override void initState() { @@ -53,17 +51,19 @@ class _SwipeToPopState extends State } void _onDragStart(DragStartDetails _) { + _width = context.size?.width ?? MediaQuery.of(context).size.width; + if (_width <= 0) _width = 1.0; _controller.stop(); } - void _onDragUpdate(DragUpdateDetails d, double width) { - if (width <= 0) return; - final next = (_controller.value + d.delta.dx / width).clamp(0.0, 1.0); + void _onDragUpdate(DragUpdateDetails d) { + final next = + (_controller.value + (d.primaryDelta ?? 0.0) / _width).clamp(0.0, 1.0); _controller.value = next; } - Future _onDragEnd(DragEndDetails d, double width) async { - final velocity = d.primaryVelocity ?? 0; + Future _onDragEnd(DragEndDetails d) async { + final velocity = d.velocity.pixelsPerSecond.dx; final pastThreshold = _controller.value > widget.popThreshold || velocity > widget.velocityThreshold; if (pastThreshold) { @@ -96,44 +96,35 @@ class _SwipeToPopState extends State return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - final gestureChild = GestureDetector( + return RawGestureDetector( behavior: HitTestBehavior.translucent, - dragStartBehavior: DragStartBehavior.down, - onHorizontalDragStart: _onDragStart, - onHorizontalDragUpdate: (d) => _onDragUpdate(d, width), - onHorizontalDragEnd: (d) => _onDragEnd(d, width), - onHorizontalDragCancel: _onDragCancel, - ); - - return Stack( - children: [ - Positioned.fill( - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) { - final t = _controller.value; - return Transform.translate( - offset: Offset(t * width, 0), - child: Opacity( - opacity: (1.0 - t * 0.35).clamp(0.0, 1.0), - child: child, - ), - ); - }, - child: widget.child, - ), + gestures: { + RightwardDragRecognizer: + GestureRecognizerFactoryWithHandlers( + () => RightwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _onDragStart + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _onDragCancel; + }, ), - if (widget.fullWidth) - Positioned.fill(child: gestureChild) - else - Positioned( - left: 0, - top: 0, - bottom: 0, - width: widget.edgeWidth, - child: gestureChild, - ), - ], + }, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; + return Transform.translate( + offset: Offset(t * width, 0), + child: Opacity( + opacity: (1.0 - t * 0.35).clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: widget.child, + ), ); }, ); From 0947863697948d9785ba95d7b846d1781fa70c8e Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 28 May 2026 16:44:59 +0700 Subject: [PATCH 169/323] =?UTF-8?q?=D0=BB=D0=B5=D0=B3=D0=BE=D0=BD=D1=8C?= =?UTF-8?q?=D0=BA=D0=BE=20=D0=BD=D0=B0=D0=B1=D1=83=D1=80=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D0=B4=D0=B8=D0=BB,=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=8C=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20FKM=20=D0=B8=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=20=D1=83?= =?UTF-8?q?=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 1 + lib/backend/modules/chats.dart | 300 +++++++++++++++++- lib/backend/modules/messages.dart | 23 ++ lib/core/cache/info_cache.dart | 226 +++++++++++++ lib/core/storage/app_database.dart | 29 ++ .../screens/chats/chat_info_screen.dart | 76 ++--- .../screens/chats/chat_list_screen.dart | 33 +- lib/frontend/screens/chats/chat_screen.dart | 233 ++++++++------ .../contacts/contact_profile_screen.dart | 28 +- .../screens/profile/notifications_screen.dart | 291 +++++++++++++++++ .../screens/profile/settings_tab.dart | 15 +- lib/frontend/widgets/message_bubble.dart | 133 ++++++-- lib/main.dart | 2 + 13 files changed, 1186 insertions(+), 204 deletions(-) create mode 100644 lib/core/cache/info_cache.dart create mode 100644 lib/frontend/screens/profile/notifications_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 67573bb..e5de6c2 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -940,6 +940,7 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); await ContactsModule.primeCacheFromDb(accountId); try { diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 96387e6..a45a8e9 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -5,12 +5,13 @@ import 'package:flutter/foundation.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; +import '../../core/cache/info_cache.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../api.dart'; import 'folders.dart'; -import 'messages.dart' show ContactCache; +import 'messages.dart' show ContactCache, CachedMessage; Map _parseParticipants(dynamic raw) { try { @@ -146,27 +147,317 @@ class CachedChat { }; } +sealed class MessageEvent { + final int chatId; + const MessageEvent(this.chatId); +} + +class MessageAddedEvent extends MessageEvent { + final CachedMessage message; + const MessageAddedEvent(super.chatId, this.message); +} + +class MessageEditedEvent extends MessageEvent { + final CachedMessage message; + const MessageEditedEvent(super.chatId, this.message); +} + +class MessageRemovedEvent extends MessageEvent { + final String messageId; + const MessageRemovedEvent(super.chatId, this.messageId); +} + +class MessageReactionsChangedEvent extends MessageEvent { + final String messageId; + final Map? reactionInfo; + const MessageReactionsChangedEvent(super.chatId, this.messageId, this.reactionInfo); +} + class ChatsModule { static const int muteOff = 0; static const int muteForever = -1; + /// Sentinel в `lastMsgText` когда последнее сообщение в чате удалено, + /// а кеша истории нет — UI должен отрисовать курсивную плашку. + static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__'; + + static final _messageEventsController = + StreamController.broadcast(); + static Stream get messageEvents => + _messageEventsController.stream; + static final ValueNotifier chatsChanged = ValueNotifier(0); static void _bump() => chatsChanged.value = chatsChanged.value + 1; static StreamSubscription? _globalPushSub; + static StreamSubscription? _globalStateSub; + + static final Set _dirtyChats = {}; + static final Set _knownChats = {}; + + static bool isChatDirty(int chatId) => _dirtyChats.contains(chatId); + static void markChatClean(int chatId) => _dirtyChats.remove(chatId); + static void markChatDirty(int chatId) => _dirtyChats.add(chatId); + static void registerKnownChat(int chatId) => _knownChats.add(chatId); static void attachGlobalPushHandlers(Api api) { _globalPushSub?.cancel(); + _globalStateSub?.cancel(); _globalPushSub = api.pushStream.listen(_handleGlobalPush); + _globalStateSub = api.stateStream.listen(_handleSessionState); + if (api.state != SessionState.online) { + _markAllKnownChatsDirty(); + } + } + + static Future _handleSessionState(SessionState state) async { + if (state == SessionState.disconnected) { + ContactInfoFetch.clear(); + PresenceFetch.clear(); + ChatInfoFetch.clear(); + await _markAllKnownChatsDirty(); + } + } + + static void resetForAccountSwitch() { + _dirtyChats.clear(); + _knownChats.clear(); + ContactInfoFetch.clear(); + PresenceFetch.clear(); + ChatInfoFetch.clear(); + } + + static Future _markAllKnownChatsDirty() async { + if (_knownChats.isEmpty) { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final rows = await AppDatabase.loadChats(accountId); + for (final row in rows) { + final id = row['id']; + if (id is int) _knownChats.add(id); + } + } + _dirtyChats.addAll(_knownChats); } static Future _handleGlobalPush(Packet packet) async { switch (packet.opcode) { + case Opcode.notifMessage: + await _handleNotifMessage(packet); case Opcode.notifMark: await _handleNotifMark(packet); + case Opcode.notifMsgReactionsChanged: + await _handleNotifMsgReactionsChanged(packet); } } + static Future _handleNotifMessage(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final msg = payload['message']; + if (msg is! Map) return; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final senderId = msg['sender'] as int?; + final msgIdStr = msg['id']?.toString(); + final msgIdInt = (msg['id'] is int) + ? msg['id'] as int + : int.tryParse(msgIdStr ?? ''); + final msgTime = msg['time'] as int?; + final msgText = msg['text'] as String?; + final status = msg['status'] as String?; + final unread = payload['unread'] as int?; + + var rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) { + try { + final chatInfo = await ChatInfoFetch.get(chatId); + if (chatInfo != null) { + await cacheServerChat(chatInfo, accountId); + } + } catch (e) { + logger.w('notifMessage: fetch info for unknown chat $chatId failed: $e'); + return; + } + rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + } + + if (status == 'REMOVED' && msgIdStr != null) { + await AppDatabase.deleteMessage(accountId, chatId, msgIdStr); + final cachedChat = CachedChat.fromDbRow(rows.first); + if (cachedChat.lastMsgId == msgIdInt) { + await _reconcileLastMessage(accountId, chatId, rows.first, unread: unread); + } else if (unread != null) { + final newRow = Map.from(rows.first); + newRow['unread_count'] = unread; + await AppDatabase.saveChats([newRow]); + } + _messageEventsController.add(MessageRemovedEvent(chatId, msgIdStr)); + _bump(); + return; + } + + CachedMessage? emittedMessage; + if (status == 'EDITED' && msgIdStr != null) { + final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr); + if (existing != null) { + Map mergedPayload; + final existingPayloadRaw = existing['payload']; + if (existingPayloadRaw is String && existingPayloadRaw.isNotEmpty) { + try { + mergedPayload = Map.from( + jsonDecode(existingPayloadRaw) as Map, + ); + } catch (_) { + mergedPayload = Map.from(msg); + } + } else { + mergedPayload = Map.from(msg); + } + for (final entry in msg.entries) { + if (entry.key == 'reactionInfo') continue; + mergedPayload[entry.key.toString()] = entry.value; + } + final newRow = Map.from(existing); + newRow['text'] = msgText; + newRow['status'] = status; + newRow['payload'] = jsonEncode(mergedPayload); + await AppDatabase.saveMessages([newRow]); + emittedMessage = CachedMessage.fromDbRow(newRow); + _messageEventsController.add(MessageEditedEvent(chatId, emittedMessage)); + } + } else if (msgIdStr != null) { + final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr); + if (existing == null) { + final cached = CachedMessage.fromPushPayload(accountId, chatId, msg); + await AppDatabase.saveMessages([cached.toDbRow()]); + emittedMessage = cached; + _messageEventsController.add(MessageAddedEvent(chatId, cached)); + } + } + + final cached = CachedChat.fromDbRow(rows.first); + final isStaleLast = status != 'REMOVED' && + msgIdInt != null && + cached.lastMsgId == msgIdInt && + status != 'EDITED'; + if (isStaleLast) { + _bump(); + return; + } + + final newRow = Map.from(rows.first); + if (status != 'REMOVED') { + if (msgIdInt != null) newRow['last_msg_id'] = msgIdInt; + if (msgTime != null) { + newRow['last_msg_time'] = msgTime; + if (status != 'EDITED') { + newRow['last_event_time'] = msgTime; + } + } + newRow['last_msg_text'] = msgText; + if (senderId != null) newRow['last_msg_sender'] = senderId; + } + if (unread != null) newRow['unread_count'] = unread; + + await AppDatabase.saveChats([newRow]); + _bump(); + } + + static Future _reconcileLastMessage( + int accountId, + int chatId, + Map chatRow, { + int? unread, + }) async { + final latest = await AppDatabase.loadMessages(accountId, chatId, limit: 1); + final newRow = Map.from(chatRow); + if (latest.isNotEmpty) { + final m = latest.first; + newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? ''); + newRow['last_msg_text'] = m['text']; + newRow['last_msg_time'] = m['time']; + newRow['last_msg_sender'] = m['sender_id']; + } else { + newRow['last_msg_id'] = null; + newRow['last_msg_text'] = lastMsgPlaceholder; + newRow['last_msg_sender'] = null; + } + if (unread != null) newRow['unread_count'] = unread; + await AppDatabase.saveChats([newRow]); + } + + /// Вызывается после успешного фетча истории чата — + /// если в превью был placeholder, заменяем его на актуальное + /// последнее сообщение из кеша. + static Future reconcileLastMessageIfPlaceholder( + int accountId, + int chatId, + ) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final chat = CachedChat.fromDbRow(rows.first); + if (chat.lastMsgText != lastMsgPlaceholder) return; + await _reconcileLastMessage(accountId, chatId, rows.first); + _bump(); + } + + static Future _handleNotifMsgReactionsChanged(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final messageId = payload['messageId']?.toString(); + if (messageId == null || messageId.isEmpty) return; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final existing = await AppDatabase.loadMessage(accountId, chatId, messageId); + if (existing == null) return; + + Map payloadMap; + final raw = existing['payload']; + if (raw is String && raw.isNotEmpty) { + try { + payloadMap = Map.from(jsonDecode(raw) as Map); + } catch (_) { + payloadMap = {}; + } + } else { + payloadMap = {}; + } + + final counters = payload['counters']; + final totalCount = payload['totalCount']; + final reactionInfo = {}; + final prev = payloadMap['reactionInfo']; + if (prev is Map && prev['yourReaction'] != null) { + reactionInfo['yourReaction'] = prev['yourReaction']; + } + if (counters is List) reactionInfo['counters'] = counters; + if (totalCount is int) reactionInfo['totalCount'] = totalCount; + if (reactionInfo['counters'] == null || (counters is List && counters.isEmpty)) { + payloadMap.remove('reactionInfo'); + } else { + payloadMap['reactionInfo'] = reactionInfo; + } + + final newRow = Map.from(existing); + newRow['payload'] = jsonEncode(payloadMap); + await AppDatabase.saveMessages([newRow]); + final emitted = payloadMap['reactionInfo'] as Map?; + _messageEventsController.add( + MessageReactionsChangedEvent(chatId, messageId, emitted), + ); + _bump(); + } + static Future _handleNotifMark(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; @@ -288,6 +579,7 @@ class ChatsModule { logger.w('cacheServerChat: parse returned null for chat=${chat['id']}'); return null; } + _knownChats.add(parsed.id); final ex = existing[parsed.id]; if (ex != null && _sameContent(ex, parsed)) { return parsed; @@ -383,7 +675,11 @@ class ChatsModule { static Future> getChats(int accountId) async { try { final rows = await AppDatabase.loadChats(accountId); - return rows.map(CachedChat.fromDbRow).toList(); + final chats = rows.map(CachedChat.fromDbRow).toList(); + for (final c in chats) { + _knownChats.add(c.id); + } + return chats; } catch (e) { logger.e("Ошибка при получении чатов: $e"); return []; diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index f74c9e0..7a29566 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -245,6 +245,29 @@ class CachedMessage { 'status': status, 'payload': payload != null ? jsonEncode(payload) : null, }; + + static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) { + List? attachments; + final attaches = msg['attaches']; + if (attaches is List && attaches.isNotEmpty) { + attachments = attaches + .whereType() + .map((a) => + MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + return CachedMessage( + id: msg['id']?.toString() ?? '', + accountId: accountId, + chatId: chatId, + senderId: msg['sender'] as int? ?? 0, + text: msg['text'] as String?, + time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, + status: (msg['status'] as String?) ?? 'sent', + payload: Map.from(msg), + attachments: attachments, + ); + } } class MessagesModule { diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart new file mode 100644 index 0000000..8fdc292 --- /dev/null +++ b/lib/core/cache/info_cache.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import '../../backend/api.dart'; +import '../protocol/opcode_map.dart'; + +Api? _api; + +void attachInfoCacheApi(Api api) { + _api = api; +} + +class _Entry { + T? value; + DateTime? fetchedAt; + DateTime? failedAt; + Future? inFlight; +} + +class InfoCache { + final Duration ttl; + final Duration failureBackoff; + final Future Function(int id) fetcher; + final Map> _entries = {}; + + InfoCache({ + required this.ttl, + required this.fetcher, + this.failureBackoff = const Duration(seconds: 10), + }); + + bool _isFresh(_Entry e) { + if (e.fetchedAt == null) return false; + return DateTime.now().difference(e.fetchedAt!) < ttl; + } + + bool _isInFailureBackoff(_Entry e) { + if (e.failedAt == null) return false; + return DateTime.now().difference(e.failedAt!) < failureBackoff; + } + + Future get(int id, {bool forceRefresh = false}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + + if (!forceRefresh && _isFresh(entry)) { + return Future.value(entry.value); + } + if (!forceRefresh && _isInFailureBackoff(entry)) { + return Future.value(null); + } + if (entry.inFlight != null) return entry.inFlight!; + + final future = _runFetch(entry, id); + entry.inFlight = future; + return future; + } + + Future _runFetch(_Entry entry, int id) async { + try { + final result = await fetcher(id); + entry.value = result; + entry.fetchedAt = DateTime.now(); + entry.failedAt = null; + return result; + } catch (_) { + entry.failedAt = DateTime.now(); + return null; + } finally { + entry.inFlight = null; + } + } + + T? peek(int id) { + final entry = _entries[id]; + if (entry == null || !_isFresh(entry)) return null; + return entry.value; + } + + void invalidate(int id) => _entries.remove(id); + void clear() => _entries.clear(); + + void putValue(int id, T value, {DateTime? at}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + entry.value = value; + entry.fetchedAt = at ?? DateTime.now(); + entry.failedAt = null; + } + + void markFailed(int id, {DateTime? at}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + entry.failedAt = at ?? DateTime.now(); + } +} + +class ContactInfoFetch { + static final _cache = InfoCache>( + ttl: const Duration(minutes: 5), + fetcher: _fetch, + ); + + static Future?> get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static Map? peek(int id) => _cache.peek(id); + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static Future?> _fetch(int id) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.contactInfo, { + 'contactIds': [id], + }); + final data = resp.payload; + if (data is! Map) return null; + final contacts = data['contacts']; + if (contacts is! List || contacts.isEmpty) return null; + final first = contacts.first; + if (first is! Map) return null; + return Map.from(first); + } +} + +class PresenceFetch { + static final _cache = InfoCache>( + ttl: const Duration(seconds: 60), + fetcher: _fetch, + ); + + static Future?> get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static Map? peek(int id) => _cache.peek(id); + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static Future?> _fetch(int id) async { + final results = await _fetchBatch([id]); + return results[id]; + } + + static Future>> getMany( + List ids, { + bool forceRefresh = false, + }) async { + final result = >{}; + final missing = []; + for (final id in ids) { + if (!forceRefresh) { + final cached = _cache.peek(id); + if (cached != null) { + result[id] = cached; + continue; + } + } + missing.add(id); + } + if (missing.isNotEmpty) { + final fetched = await _fetchBatch(missing); + final now = DateTime.now(); + for (final id in missing) { + final value = fetched[id]; + if (value != null) { + _cache.putValue(id, value, at: now); + result[id] = value; + } else { + _cache.markFailed(id, at: now); + } + } + } + return result; + } + + static Future>> _fetchBatch(List ids) async { + final api = _api; + if (api == null || api.state != SessionState.online || ids.isEmpty) { + return const {}; + } + final resp = await api.sendRequest(Opcode.contactPresence, { + 'contactIds': ids, + }); + final data = resp.payload; + if (data is! Map) return const {}; + final presence = data['presence']; + if (presence is! Map) return const {}; + final out = >{}; + for (final id in ids) { + final entry = presence[id.toString()] ?? presence[id]; + if (entry is Map) { + out[id] = Map.from(entry); + } + } + return out; + } +} + +class ChatInfoFetch { + static final _cache = InfoCache>( + ttl: const Duration(minutes: 5), + fetcher: _fetch, + ); + + static Future?> get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static Map? peek(int id) => _cache.peek(id); + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static Future?> _fetch(int id) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.chatInfo, { + 'chatIds': [id], + }); + final data = resp.payload; + if (data is! Map) return null; + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return null; + final first = chats.first; + if (first is! Map) return null; + return Map.from(first); + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 8a1d41b..2f62001 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -587,4 +587,33 @@ class AppDatabase { whereArgs: [accountId, chatId], ); } + + static Future?> loadMessage( + int accountId, + int chatId, + String messageId, + ) async { + final db = await _instance; + final rows = await db.query( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first; + } + + static Future deleteMessage( + int accountId, + int chatId, + String messageId, + ) async { + final db = await _instance; + await db.delete( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + ); + } } diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index ef5a480..8965c7a 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -3,9 +3,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart' show ContactCache; -import '../../../core/protocol/opcode_map.dart'; +import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; -import '../../../main.dart' as main; class _MemberInfo { final int id; @@ -96,21 +95,13 @@ class _ChatInfoScreenState extends State { final profile = await AppDatabase.loadActiveProfile(); _myId = profile?.id ?? 0; - final packet = await main.api.sendRequest( - Opcode.chatInfo, - {'chatIds': [widget.chatId]}, - ); - if (!packet.isOk || !mounted) { - if (mounted) setState(() => _isLoading = false); + final info = await ChatInfoFetch.get(widget.chatId); + if (!mounted) return; + if (info == null) { + setState(() => _isLoading = false); return; } - - final chats = (packet.payload as Map?)?['chats'] as List?; - if (chats == null || chats.isEmpty) { - if (mounted) setState(() => _isLoading = false); - return; - } - _chatData = Map.from(chats.first as Map); + _chatData = info; if (widget.chatType == 'DIALOG') { final parts = _chatData!['participants'] as Map? ?? {}; @@ -123,32 +114,19 @@ class _ChatInfoScreenState extends State { } if (_otherId != null) { - final cp = await main.api.sendRequest( - Opcode.contactInfo, - {'contactIds': [_otherId]}, - ); - if (cp.isOk) { - final contacts = (cp.payload as Map?)?['contacts'] as List?; - if (contacts != null && contacts.isNotEmpty) { - _contactData = Map.from(contacts.first as Map); - final opts = _contactData!['options']; - _isBot = (opts is List) && opts.contains('BOT'); - } + final contact = await ContactInfoFetch.get(_otherId!); + if (contact != null) { + _contactData = contact; + final opts = _contactData!['options']; + _isBot = (opts is List) && opts.contains('BOT'); } - final pp = await main.api.sendRequest( - Opcode.contactPresence, - {'contactIds': [_otherId]}, - ); - if (pp.isOk) { - final presence = (pp.payload as Map?)?['presence'] as Map?; - final p = presence?[_otherId.toString()] ?? presence?[_otherId]; - if (p is Map) { - _seenTime = p['seen'] as int?; - final st = (p['status'] as int?) ?? 0; - _presenceStatus = st; - _isOnline = st == 1; - } + final presence = await PresenceFetch.get(_otherId!); + if (presence != null) { + _seenTime = presence['seen'] as int?; + final st = (presence['status'] as int?) ?? 0; + _presenceStatus = st; + _isOnline = st == 1; } } } else if (widget.chatType == 'CHAT') { @@ -162,25 +140,9 @@ class _ChatInfoScreenState extends State { if (id != null) memberIds.add(id); } - final Map presenceMap = {}; + Map> presenceMap = {}; if (memberIds.isNotEmpty) { - final pp = await main.api.sendRequest( - Opcode.contactPresence, - {'contactIds': memberIds}, - ); - if (pp.isOk) { - final presence = (pp.payload as Map?)?['presence'] as Map?; - if (presence != null) { - for (final e in presence.entries) { - final id = e.key is int - ? e.key as int - : int.tryParse(e.key.toString()); - if (id != null && e.value is Map) { - presenceMap[id] = e.value as Map; - } - } - } - } + presenceMap = await PresenceFetch.getMany(memberIds); } _onlineCount = 0; diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 655e5f6..624382f 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1367,10 +1367,15 @@ class _ChatListScreenState extends State // chat.isOfficial covers contacts from the login payload. final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial; + final isPlaceholder = + chat.lastMsgText == ChatsModule.lastMsgPlaceholder; + final previewText = isPlaceholder + ? 'зайдите в чат для подгрузки' + : (chat.lastMsgTextOneLine ?? ''); return _buildChatItem( chat.id.toString(), name ?? "Пользователь", - chat.lastMsgTextOneLine ?? '', + previewText, _formatTime(chat.lastMsgTime), avatar ?? "", isOnline: chat.isOnline, @@ -1379,20 +1384,25 @@ class _ChatListScreenState extends State isVerified: isVerified, isPinned: isPinned, chatType: "DIALOG", + messageItalic: isPlaceholder, ); } else { - final name = chat.lastMsgSenderId != null + final isPlaceholder = + chat.lastMsgText == ChatsModule.lastMsgPlaceholder; + final sender = chat.lastMsgSenderId != null ? ContactCache.get(chat.lastMsgSenderId!) : null; String fullMsg = ""; - - if (name?.isNotEmpty == true && chat.id != 0) { - fullMsg += "$name: "; - } - - if (chat.lastMsgText?.isNotEmpty == true) { - fullMsg += chat.lastMsgText ?? ""; + if (isPlaceholder) { + fullMsg = 'зайдите в чат для подгрузки'; + } else { + if (sender?.isNotEmpty == true && chat.id != 0) { + fullMsg += "$sender: "; + } + if (chat.lastMsgText?.isNotEmpty == true) { + fullMsg += chat.lastMsgText ?? ""; + } } return _buildChatItem( @@ -1409,6 +1419,7 @@ class _ChatListScreenState extends State isVerified: chat.isOfficial, isPinned: isPinned, chatType: chat.type, + messageItalic: isPlaceholder, ); } }, childCount: totalItems), @@ -2049,6 +2060,7 @@ class _ChatListScreenState extends State bool isVerified = false, bool isPinned = false, String chatType = "CHAT", + bool messageItalic = false, }) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedChats.contains(id); @@ -2230,6 +2242,9 @@ class _ChatListScreenState extends State fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400, + fontStyle: messageItalic + ? FontStyle.italic + : FontStyle.normal, height: 1.2, ), maxLines: 1, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 6dcd73d..06467c2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -13,11 +13,11 @@ import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; -import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/cache/info_cache.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_message_actions_style.dart'; @@ -89,6 +89,27 @@ class _ChatScreenState extends State final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus()); StreamSubscription? _uploadSub; StreamSubscription? _pushSub; + StreamSubscription? _messageEventSub; + final Map?>> _reactionNotifiers = {}; + + ValueNotifier?> _reactionNotifierFor(CachedMessage m) { + final existing = _reactionNotifiers[m.id]; + if (existing != null) return existing; + final info = m.payload?['reactionInfo']; + final notifier = ValueNotifier?>( + info is Map ? Map.from(info) : null, + ); + _reactionNotifiers[m.id] = notifier; + return notifier; + } + + void _pruneReactionNotifiers() { + final liveIds = _messages.map((m) => m.id).toSet(); + final dead = _reactionNotifiers.keys.where((id) => !liveIds.contains(id)).toList(); + for (final id in dead) { + _reactionNotifiers.remove(id)?.dispose(); + } + } final Set _typingUserIds = {}; final Map _typingTimers = {}; int _otherStatus = 0; @@ -130,10 +151,12 @@ class _ChatScreenState extends State _showAttachmentPanel.addListener(_onAttachPanelToggle); _pushSub = api.pushStream .where((p) => - p.opcode == Opcode.notifMessage || p.opcode == Opcode.notifMark || p.opcode == Opcode.notifTyping) .listen(_onIncomingPush); + _messageEventSub = ChatsModule.messageEvents + .where((e) => e.chatId == widget.chatId) + .listen(_onMessageEvent); _floatingDateAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), @@ -145,9 +168,42 @@ class _ChatScreenState extends State reverseCurve: Curves.easeIn, ); + unawaited(_fastPreloadCache()); WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); } + Future _fastPreloadCache() async { + final p = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + _myId = p?.id ?? 0; + + ChatsModule.getChat(_myId, widget.chatId).then((value) { + if (mounted && value.isNotEmpty) { + setState(() { + chat = value.first; + }); + _recomputeHeaderStatus(); + } + }).catchError((_) {}); + + final firstRows = await AppDatabase.loadMessages( + _myId, + widget.chatId, + limit: 20, + ); + if (!mounted) return; + if (firstRows.isNotEmpty) { + final first = firstRows.reversed + .map((r) => CachedMessage.fromDbRow(r)) + .toList(); + setState(() { + _messages = first; + _isLoading = false; + _onLoadingFinished(); + }); + } + } + void _onFirstFrameRendered(Duration _) { if (!mounted) return; if (widget.embedded) { @@ -192,37 +248,15 @@ class _ChatScreenState extends State } Future _loadHistory() async { - final activeProfile = await AppDatabase.loadActiveProfile(); - _myId = activeProfile?.id ?? 0; - ChatsModule.getChat(_myId, widget.chatId).then((value) { - if (mounted && value.isNotEmpty) { - setState(() { chat = value.first; }); - _recomputeHeaderStatus(); - } - }).catchError((_) {}); + if (_myId == 0) { + final activeProfile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + _myId = activeProfile?.id ?? 0; + } if (widget.chatType == 'DIALOG') { unawaited(_loadOtherPresence()); } - - final firstRows = await AppDatabase.loadMessages( - _myId, - widget.chatId, - limit: 20, - ); - if (mounted && firstRows.isNotEmpty) { - final first = firstRows.reversed - .map((r) => CachedMessage.fromDbRow(r)) - .toList(); - setState(() { - _messages = first; - if (api.state == SessionState.online) { - _isLoading = false; - _onLoadingFinished(); - } - }); - } - - unawaited(_loadRemainingHistory()); + await _loadRemainingHistory(); } Future _loadRemainingHistory() async { @@ -235,8 +269,20 @@ class _ChatScreenState extends State _applyMergedMessages(fullRows); } + if (!ChatsModule.isChatDirty(widget.chatId) && fullRows.isNotEmpty) { + if (mounted) { + setState(() { + _isLoading = false; + _onLoadingFinished(); + }); + } + _loadForwardedSenderNames(); + return; + } + try { await messagesModule.fetchHistory(_myId, widget.chatId); + ChatsModule.markChatClean(widget.chatId); final updatedRows = await AppDatabase.loadMessages( _myId, widget.chatId, @@ -245,6 +291,7 @@ class _ChatScreenState extends State if (mounted) { _applyMergedMessages(updatedRows, markLoaded: true); } + unawaited(ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId)); _loadForwardedSenderNames(); } catch (e) { debugPrint('Error fetching history: $e'); @@ -280,6 +327,33 @@ class _ChatScreenState extends State _onLoadingFinished(); } }); + if (changed) { + _syncReactionNotifiersFromMessages(); + _pruneReactionNotifiers(); + } + } + + void _syncReactionNotifiersFromMessages() { + for (final m in _messages) { + final info = m.payload?['reactionInfo']; + final value = info is Map ? Map.from(info) : null; + final existing = _reactionNotifiers[m.id]; + if (existing == null) { + _reactionNotifiers[m.id] = ValueNotifier(value); + } else if (!_reactionsEqual(existing.value, value)) { + existing.value = value; + } + } + } + + bool _reactionsEqual(Map? a, Map? b) { + if (identical(a, b)) return true; + if (a == null || b == null) return false; + if (a.length != b.length) return false; + for (final k in a.keys) { + if (a[k].toString() != b[k].toString()) return false; + } + return true; } bool _sameMessage(CachedMessage a, CachedMessage b) { @@ -311,6 +385,11 @@ class _ChatScreenState extends State _showAttachmentPanel.dispose(); _uploadSub?.cancel(); _pushSub?.cancel(); + _messageEventSub?.cancel(); + for (final n in _reactionNotifiers.values) { + n.dispose(); + } + _reactionNotifiers.clear(); for (final t in _typingTimers.values) { t.cancel(); } @@ -358,8 +437,6 @@ class _ChatScreenState extends State void _onIncomingPush(Packet packet) { if (!mounted) return; switch (packet.opcode) { - case Opcode.notifMessage: - _onIncomingMessage(packet); case Opcode.notifMark: _onMessageRead(packet); case Opcode.notifTyping: @@ -367,23 +444,43 @@ class _ChatScreenState extends State } } + void _onMessageEvent(MessageEvent event) { + if (!mounted) return; + switch (event) { + case MessageAddedEvent(:final message): + if (message.senderId == _myId) return; + if (_messages.any((m) => m.id == message.id)) return; + setState(() { + _lastSentId = message.id; + _messages.add(message); + }); + _clearTyping(message.senderId); + Haptics.tap(); + _scrollToBottom(); + case MessageEditedEvent(:final message): + final idx = _messages.indexWhere((m) => m.id == message.id); + if (idx == -1) return; + setState(() => _messages[idx] = message); + case MessageRemovedEvent(:final messageId): + final idx = _messages.indexWhere((m) => m.id == messageId); + if (idx == -1) return; + setState(() => _messages.removeAt(idx)); + _reactionNotifiers.remove(messageId)?.dispose(); + case MessageReactionsChangedEvent(:final messageId, :final reactionInfo): + _reactionNotifiers[messageId]?.value = reactionInfo; + } + } + Future _loadOtherPresence() async { if (_myId == 0) return; final otherId = widget.chatId ^ _myId; if (otherId <= 0) return; try { - final p = await api.sendRequest( - Opcode.contactPresence, - {'contactIds': [otherId]}, - ); - if (!mounted) return; - final presence = (p.payload as Map?)?['presence'] as Map?; - final entry = presence?[otherId.toString()] ?? presence?[otherId]; - if (entry is Map) { - _otherStatus = (entry['status'] as int?) ?? 0; - _otherSeenTime = entry['seen'] as int?; - _recomputeHeaderStatus(); - } + final entry = await PresenceFetch.get(otherId); + if (!mounted || entry == null) return; + _otherStatus = (entry['status'] as int?) ?? 0; + _otherSeenTime = entry['seen'] as int?; + _recomputeHeaderStatus(); } catch (_) {} } @@ -463,53 +560,6 @@ class _ChatScreenState extends State }); } - void _onIncomingMessage(Packet packet) { - if (!mounted) return; - final payload = packet.payload; - if (payload is! Map) return; - final chatId = payload['chatId']; - if (chatId != widget.chatId) return; - final msg = payload['message']; - if (msg is! Map) return; - - final senderId = msg['sender']; - if (senderId is! int) return; - if (senderId == _myId) return; - - final msgId = msg['id']?.toString(); - if (msgId == null || msgId.isEmpty) return; - if (_messages.any((m) => m.id == msgId)) return; - - List? attachments; - final attaches = msg['attaches']; - if (attaches is List && attaches.isNotEmpty) { - attachments = attaches - .whereType() - .map((a) => MessageAttachment.fromMap(Map.from(a))) - .toList(); - } - - final cached = CachedMessage( - id: msgId, - accountId: _myId, - chatId: widget.chatId, - senderId: senderId, - text: msg['text'] as String?, - time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, - status: 'sent', - payload: Map.from(msg), - attachments: attachments, - ); - - setState(() { - _lastSentId = msgId; - _messages.add(cached); - }); - _clearTyping(senderId); - Haptics.tap(); - _scrollToBottom(); - } - Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; @@ -1006,6 +1056,7 @@ class _ChatScreenState extends State nextMessage: nextMessage, chatType: chat?.type ?? 'CHAT', overrideStatus: _effectiveStatus(message), + reactionsListenable: _reactionNotifierFor(message), ); final pressable = _LongPressBubble( diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index e502a18..b433648 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -2,10 +2,9 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../core/protocol/opcode_map.dart'; +import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; -import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/swipe_route.dart'; import '../chats/chat_screen.dart'; @@ -41,25 +40,18 @@ class _ContactProfileScreenState extends State { Future _load() async { try { final results = await Future.wait([ - api.sendRequest(Opcode.contactInfo, {'contactIds': [widget.contactId]}), - api.sendRequest(Opcode.contactPresence, {'contactIds': [widget.contactId]}), + ContactInfoFetch.get(widget.contactId), + PresenceFetch.get(widget.contactId), ]); if (!mounted) return; - final infoPacket = results[0]; - if (infoPacket.isOk) { - final contacts = (infoPacket.payload as Map?)?['contacts'] as List?; - if (contacts != null && contacts.isNotEmpty) { - _contact = Map.from(contacts.first as Map); - } + final contact = results[0]; + if (contact != null) { + _contact = contact; } - final presencePacket = results[1]; - if (presencePacket.isOk) { - final presence = (presencePacket.payload as Map?)?['presence'] as Map?; - final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId]; - if (p is Map) { - _seenTime = p['seen'] as int?; - _presenceStatus = (p['status'] as int?) ?? 0; - } + final presence = results[1]; + if (presence != null) { + _seenTime = presence['seen'] as int?; + _presenceStatus = (presence['status'] as int?) ?? 0; } } catch (e) { if (mounted) showCustomNotification(context, 'Ошибка: $e'); diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart new file mode 100644 index 0000000..ee5b9f5 --- /dev/null +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class NotificationsScreen extends StatefulWidget { + const NotificationsScreen({super.key}); + + @override + State createState() => _NotificationsScreenState(); +} + +class _NotificationsScreenState extends State { + bool _fkmEnabled = false; + bool _personalChatsEnabled = true; + bool _groupsEnabled = true; + bool _channelsEnabled = true; + String _selectedSound = 'По умолчанию'; + + static const List _sounds = [ + 'По умолчанию', + 'Колокольчик', + 'Звон', + 'Капля', + 'Беззвучно', + ]; + + Future _pickSound() async { + final cs = Theme.of(context).colorScheme; + final picked = await showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Row( + children: [ + Text( + 'Звук уведомления', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + for (final s in _sounds) + ListTile( + onTap: () => Navigator.of(context).pop(s), + leading: Icon( + s == _selectedSound + ? Symbols.radio_button_checked + : Symbols.radio_button_unchecked, + color: s == _selectedSound + ? cs.primary + : cs.onSurfaceVariant, + ), + title: Text( + s, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + ), + ), + ), + ], + ), + ), + ); + }, + ); + if (picked != null && picked != _selectedSound) { + setState(() => _selectedSound = picked); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBarM3E( + titleText: 'Уведомления', + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: [ + _sectionHeader(cs, 'FKM'), + _card(cs, [ + _toggleRow( + cs, + icon: Symbols.notifications_active, + label: 'Включить уведомления', + subtitle: + 'Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.', + value: _fkmEnabled, + onChanged: (v) => setState(() => _fkmEnabled = v), + ), + ]), + const SizedBox(height: 20), + _sectionHeader(cs, 'Настройки уведомлений'), + _card(cs, [ + _toggleRow( + cs, + icon: Symbols.person, + label: 'Уведомления от личных чатов', + value: _personalChatsEnabled, + onChanged: (v) => setState(() => _personalChatsEnabled = v), + ), + _divider(cs), + _toggleRow( + cs, + icon: Symbols.groups, + label: 'Уведомления от групп', + value: _groupsEnabled, + onChanged: (v) => setState(() => _groupsEnabled = v), + ), + _divider(cs), + _toggleRow( + cs, + icon: Symbols.campaign, + label: 'Уведомления от каналов', + value: _channelsEnabled, + onChanged: (v) => setState(() => _channelsEnabled = v), + ), + ]), + const SizedBox(height: 20), + _sectionHeader(cs, 'Звук'), + _card(cs, [ + _tappableRow( + cs, + icon: Symbols.music_note, + label: 'Звук уведомления', + trailingText: _selectedSound, + onTap: _pickSound, + ), + ]), + ], + ), + ), + ); + } + + Widget _sectionHeader(ColorScheme cs, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Text( + title, + style: TextStyle( + color: cs.primary, + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + ); + } + + Widget _card(ColorScheme cs, List children) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column(children: children), + ); + } + + Widget _divider(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ); + } + + Widget _toggleRow( + ColorScheme cs, { + required IconData icon, + required String label, + String? subtitle, + required bool value, + required ValueChanged onChanged, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(!value), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.3, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ); + } + + Widget _tappableRow( + ColorScheme cs, { + required IconData icon, + required String label, + required String trailingText, + required VoidCallback onTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + trailingText, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + const SizedBox(width: 6), + Icon(Symbols.chevron_right, color: cs.outline, size: 20), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index dd40169..d8c7ba1 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import '../../../backend/modules/chats.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; @@ -20,6 +21,7 @@ import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'edit_profile_screen.dart'; import 'info_screen.dart'; +import 'notifications_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -206,6 +208,7 @@ class _SettingsTabState extends State { } ContactCache.clear(); TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); try { await api.connect(); } catch (_) {} @@ -309,9 +312,17 @@ child: _buildSection( context, cs, items: [ - const _SettingsItem( + _SettingsItem( icon: Symbols.notifications_active, - label: 'Уведомления и звук', + label: 'Уведомления', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const NotificationsScreen(), + ), + ); + }, ), _SettingsItem( icon: Symbols.vibration, diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index dfe82b3..363fcf0 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,4 +1,5 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -59,6 +60,7 @@ class MessageBubble extends StatelessWidget { final CachedMessage? nextMessage; final String chatType; final String? overrideStatus; + final ValueListenable?>? reactionsListenable; const MessageBubble({ super.key, @@ -69,6 +71,7 @@ class MessageBubble extends StatelessWidget { this.nextMessage, required this.chatType, this.overrideStatus, + this.reactionsListenable, }); bool _computeHasPhotoWithCaption() { @@ -304,32 +307,52 @@ class MessageBubble extends StatelessWidget { radius: 15, backgroundColor: Color(0x00000000), ), - ListenableBuilder( - listenable: Listenable.merge( - [AppBubbleShape.current, AppBubbleBehavior.current], - ), - builder: (context, child) { - return Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.75, + Column( + crossAxisAlignment: + isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + ListenableBuilder( + listenable: Listenable.merge( + [AppBubbleShape.current, AppBubbleBehavior.current], ), - decoration: BoxDecoration( - color: isMe - ? cs.primaryContainer - : cs.surfaceContainerHighest, - borderRadius: _borderRadiusFor( - AppBubbleShape.current.value, - AppBubbleBehavior.current.value, - shape, - hasPhotoCap, - hasMultiPhotos, - ), - ), - padding: padding, - child: child, - ); - }, - child: _buildContent(ctx), + builder: (context, child) { + return Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? cs.primaryContainer + : cs.surfaceContainerHighest, + borderRadius: _borderRadiusFor( + AppBubbleShape.current.value, + AppBubbleBehavior.current.value, + shape, + hasPhotoCap, + hasMultiPhotos, + ), + ), + padding: padding, + child: child, + ); + }, + child: _buildContent(ctx), + ), + AnimatedSize( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + alignment: isMe + ? Alignment.centerRight + : Alignment.centerLeft, + child: reactionsListenable != null + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, info, _) => + _buildReactionsBarFor(cs, info), + ) + : _buildReactionsBar(cs), + ), + ], ), ], ), @@ -351,6 +374,66 @@ class MessageBubble extends StatelessWidget { } } + Widget _buildReactionsBar(ColorScheme cs) { + final info = message.payload?['reactionInfo']; + return _buildReactionsBarFor(cs, info is Map ? info : null); + } + + Widget _buildReactionsBarFor(ColorScheme cs, Map? info) { + if (info == null) return const SizedBox.shrink(); + final counters = info['counters']; + if (counters is! List || counters.isEmpty) return const SizedBox.shrink(); + final yourReaction = info['yourReaction']?.toString(); + + final chips = []; + for (final c in counters) { + if (c is! Map) continue; + final reaction = c['reaction']?.toString(); + final count = c['count']; + if (reaction == null || reaction.isEmpty) continue; + final isYours = yourReaction == reaction; + chips.add( + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isYours + ? cs.primary.withValues(alpha: 0.18) + : cs.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isYours + ? cs.primary.withValues(alpha: 0.45) + : cs.outlineVariant.withValues(alpha: 0.35), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(reaction, style: const TextStyle(fontSize: 14)), + if (count is int && count > 1) ...[ + const SizedBox(width: 4), + Text( + count.toString(), + style: TextStyle( + color: isYours ? cs.primary : cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ); + } + if (chips.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap(spacing: 4, runSpacing: 4, children: chips), + ); + } + Widget _buildControlContent(ColorScheme cs) { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { diff --git a/lib/main.dart b/lib/main.dart index 905dedb..9f85669 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:m3e_collection/m3e_collection.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; +import 'core/cache/info_cache.dart'; import 'core/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_bubble_behavior.dart'; @@ -63,6 +64,7 @@ void main() async { if (activeAccountId != null) { await ContactsModule.primeCacheFromDb(activeAccountId); } + attachInfoCacheApi(api); ChatsModule.attachGlobalPushHandlers(api); await api.connect(); From 1481a4af2c8e02f430eb4185ecfb0213440f6752 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 28 May 2026 21:00:38 +0700 Subject: [PATCH 170/323] =?UTF-8?q?=D1=8D=D0=BC=D0=BE=D0=B7=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/message_bubble.dart | 264 ++++++++++++++--------- 1 file changed, 167 insertions(+), 97 deletions(-) diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 363fcf0..ca642c6 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -23,6 +23,7 @@ class _BubbleCtx { final MessageType contentType; final bool hasPhotoWithCaption; final bool hasMultiplePhotosNoCaption; + final Map? reactionInfo; _BubbleCtx({ required this.context, @@ -32,6 +33,7 @@ class _BubbleCtx { required this.contentType, required this.hasPhotoWithCaption, required this.hasMultiplePhotosNoCaption, + this.reactionInfo, }) : dim = text.withValues(alpha: 0.7); } @@ -262,16 +264,6 @@ class MessageBubble extends StatelessWidget { final hasMultiPhotos = _computeHasMultiplePhotosNoCaption(); final textColor = bubbleTextColor(context); - final ctx = _BubbleCtx( - context: context, - cs: cs, - text: textColor, - shape: shape, - contentType: contentType, - hasPhotoWithCaption: hasPhotoCap, - hasMultiplePhotosNoCaption: hasMultiPhotos, - ); - final topMargin = _topMarginFor(contentType, shape); final bottomMargin = _bottomMarginFor(contentType, shape); final padding = _paddingFor(contentType, shape); @@ -282,6 +274,12 @@ class MessageBubble extends StatelessWidget { nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId; + final bubbleListenable = Listenable.merge([ + AppBubbleShape.current, + AppBubbleBehavior.current, + ?reactionsListenable, + ]); + return GestureDetector( onTap: Haptics.tap, child: Padding( @@ -312,46 +310,63 @@ class MessageBubble extends StatelessWidget { isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ ListenableBuilder( - listenable: Listenable.merge( - [AppBubbleShape.current, AppBubbleBehavior.current], - ), - builder: (context, child) { - return Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.75, - ), - decoration: BoxDecoration( - color: isMe - ? cs.primaryContainer - : cs.surfaceContainerHighest, - borderRadius: _borderRadiusFor( - AppBubbleShape.current.value, - AppBubbleBehavior.current.value, - shape, - hasPhotoCap, - hasMultiPhotos, + listenable: bubbleListenable, + builder: (context, _) { + final reactionInfo = _resolveReactionInfo(); + final ctx = _BubbleCtx( + context: context, + cs: cs, + text: textColor, + shape: shape, + contentType: contentType, + hasPhotoWithCaption: hasPhotoCap, + hasMultiplePhotosNoCaption: hasMultiPhotos, + reactionInfo: reactionInfo, + ); + return AnimatedSize( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + alignment: isMe + ? Alignment.bottomRight + : Alignment.bottomLeft, + child: Container( + constraints: BoxConstraints( + maxWidth: + MediaQuery.sizeOf(context).width * 0.75, ), + decoration: BoxDecoration( + color: isMe + ? cs.primaryContainer + : cs.surfaceContainerHighest, + borderRadius: _borderRadiusFor( + AppBubbleShape.current.value, + AppBubbleBehavior.current.value, + shape, + hasPhotoCap, + hasMultiPhotos, + ), + ), + padding: padding, + child: _buildContent(ctx), ), - padding: padding, - child: child, ); }, - child: _buildContent(ctx), - ), - AnimatedSize( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - alignment: isMe - ? Alignment.centerRight - : Alignment.centerLeft, - child: reactionsListenable != null - ? ValueListenableBuilder?>( - valueListenable: reactionsListenable!, - builder: (context, info, _) => - _buildReactionsBarFor(cs, info), - ) - : _buildReactionsBar(cs), ), + if (contentType != MessageType.text) + AnimatedSize( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + alignment: isMe + ? Alignment.centerRight + : Alignment.centerLeft, + child: reactionsListenable != null + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, info, _) => + _buildReactionsBarFor(cs, info), + ) + : _buildReactionsBar(cs), + ), ], ), ], @@ -361,6 +376,16 @@ class MessageBubble extends StatelessWidget { ); } + Map? _resolveReactionInfo() { + if (reactionsListenable != null) { + final v = reactionsListenable!.value; + if (v != null) return v; + } + final info = message.payload?['reactionInfo']; + if (info is Map) return info; + return null; + } + Widget _buildContent(_BubbleCtx ctx) { switch (ctx.contentType) { case MessageType.control: @@ -380,9 +405,18 @@ class MessageBubble extends StatelessWidget { } Widget _buildReactionsBarFor(ColorScheme cs, Map? info) { - if (info == null) return const SizedBox.shrink(); + final chips = _buildReactionChipsFor(cs, info); + if (chips.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap(spacing: 4, runSpacing: 4, children: chips), + ); + } + + List _buildReactionChipsFor(ColorScheme cs, Map? info) { + if (info == null) return const []; final counters = info['counters']; - if (counters is! List || counters.isEmpty) return const SizedBox.shrink(); + if (counters is! List || counters.isEmpty) return const []; final yourReaction = info['yourReaction']?.toString(); final chips = []; @@ -394,30 +428,24 @@ class MessageBubble extends StatelessWidget { final isYours = yourReaction == reaction; chips.add( Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: isYours - ? cs.primary.withValues(alpha: 0.18) - : cs.surfaceContainerHighest.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isYours - ? cs.primary.withValues(alpha: 0.45) - : cs.outlineVariant.withValues(alpha: 0.35), - width: 1, - ), + ? cs.primary.withValues(alpha: 0.22) + : Colors.black.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(reaction, style: const TextStyle(fontSize: 14)), + Text(reaction, style: const TextStyle(fontSize: 13)), if (count is int && count > 1) ...[ - const SizedBox(width: 4), + const SizedBox(width: 3), Text( count.toString(), style: TextStyle( color: isYours ? cs.primary : cs.onSurfaceVariant, - fontSize: 12, + fontSize: 11, fontWeight: FontWeight.w600, ), ), @@ -427,11 +455,7 @@ class MessageBubble extends StatelessWidget { ), ); } - if (chips.isEmpty) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(top: 4), - child: Wrap(spacing: 4, runSpacing: 4, children: chips), - ); + return chips; } Widget _buildControlContent(ColorScheme cs) { @@ -504,49 +528,95 @@ class MessageBubble extends StatelessWidget { final displaySender = ContactCache.get(message.senderId); + final reactionChips = _buildReactionChipsFor(ctx.cs, ctx.reactionInfo); + final hasReactions = reactionChips.isNotEmpty; + + final textWidget = isForwarded + ? _buildForwardedInlineText(ctx, forwarded) + : Text( + message.text ?? '', + style: TextStyle( + color: ctx.text, + fontSize: 16, + height: 1.3, + ), + ); + + final metaWidget = Text( + message.status == 'EDITED' + ? '${_formatTime(message.time)} ред.' + : _formatTime(message.time), + style: TextStyle(color: ctx.dim, fontSize: 10), + ); + + final showSender = message.senderId != message.accountId && + prevMessage?.senderId != message.senderId && + chatType == "CHAT"; + + if (hasReactions) { + return IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showSender) + Text( + displaySender ?? "", + textAlign: TextAlign.left, + style: TextStyle(color: ctx.text), + ), + textWidget, + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Wrap( + spacing: 4, + runSpacing: 4, + children: reactionChips, + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: metaWidget, + ), + if (isMe) ...[ + const SizedBox(width: 4), + _buildStatusIcon(ctx), + ], + ], + ), + ], + ), + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (message.senderId != message.accountId && - prevMessage?.senderId != message.senderId && - chatType == "CHAT") + if (showSender) Text( displaySender ?? "", textAlign: TextAlign.left, style: TextStyle(color: ctx.text), ), Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible( - child: isForwarded - ? _buildForwardedInlineText(ctx, forwarded) - : Text( - message.text ?? '', - style: TextStyle( - color: ctx.text, - fontSize: 16, - height: 1.3, - ), - ), - ), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - message.status == 'EDITED' - ? '${_formatTime(message.time)} ред.' - : _formatTime(message.time), - style: TextStyle(color: ctx.dim, fontSize: 10), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible(child: textWidget), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: metaWidget, ), - ), - if (isMe) ...[ - const SizedBox(width: 4), - _buildStatusIcon(ctx), + if (isMe) ...[ + const SizedBox(width: 4), + _buildStatusIcon(ctx), + ], ], - ], - ), + ), ], ); } From d8a2b8ec6d811196aee148a366d237cd43ad6462 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 28 May 2026 21:29:47 +0700 Subject: [PATCH 171/323] =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=80=20?= =?UTF-8?q?=D1=88=D1=80=D0=B8=D1=84=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_fonts.dart | 2 +- lib/frontend/screens/profile/font_settings_screen.dart | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/core/config/app_fonts.dart b/lib/core/config/app_fonts.dart index 74d3d59..f99d1f9 100644 --- a/lib/core/config/app_fonts.dart +++ b/lib/core/config/app_fonts.dart @@ -23,7 +23,7 @@ class AppFonts { static const String customPrefKey = 'app_custom_fonts'; static const String customPrefix = 'g:'; - static const double minScale = 0.85; + static const double minScale = 0.60; static const double maxScale = 1.35; static const double defaultScale = 1.0; diff --git a/lib/frontend/screens/profile/font_settings_screen.dart b/lib/frontend/screens/profile/font_settings_screen.dart index ba3fd15..7436b6a 100644 --- a/lib/frontend/screens/profile/font_settings_screen.dart +++ b/lib/frontend/screens/profile/font_settings_screen.dart @@ -378,7 +378,9 @@ class _FontSizeControl extends StatelessWidget { value: AppFonts.clampScale(scale), min: AppFonts.minScale, max: AppFonts.maxScale, - divisions: 10, + divisions: + ((AppFonts.maxScale - AppFonts.minScale) / 0.05) + .round(), onChanged: onChanged, onChangeEnd: onChangeEnd, ), From b3edf7249eea0ca408a403d05be014a9ec66d709 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Thu, 28 May 2026 22:05:37 +0700 Subject: [PATCH 172/323] =?UTF-8?q?=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=202=D1=84=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 84 +++++++----- .../profile/password_entry_screen.dart | 120 ++++++++---------- 2 files changed, 104 insertions(+), 100 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index e5de6c2..75449dd 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -621,15 +621,17 @@ class AccountModule { String? hint, }) async { _ensureOnline(); + final capabilities = [0, if (hint != null) 3, 4]; final payload = { - 'expectedCapabilities': [0, 3, 4], + 'expectedCapabilities': capabilities, 'trackId': trackId, 'password': password, }; if (hint != null) payload['hint'] = hint; - final packet = await _api.sendRequest(Opcode.authSet2fa, payload); - _checkPacketError(packet, 'confirm2fa'); - return _processProfileUpdate(packet); + return _processProfileUpdate( + _api.sendRequest(Opcode.authSet2fa, payload), + 'confirm2fa', + ); } // 2FA Management (when already set) @@ -707,15 +709,16 @@ class AccountModule { } final payload = { - 'expectedCapabilities': [1, 3], + 'expectedCapabilities': [1, if (hint != null) 3], 'trackId': trackId, 'password': newPassword, }; if (hint != null) payload['hint'] = hint; - final packet = await _api.sendRequest(Opcode.authSet2fa, payload); - _checkPacketError(packet, 'update2faPassword'); - return _processProfileUpdate(packet); + return _processProfileUpdate( + _api.sendRequest(Opcode.authSet2fa, payload), + 'update2faPassword', + ); } Future update2faEmail({ @@ -740,9 +743,10 @@ class AccountModule { 'expectedCapabilities': [4], 'trackId': trackId, }; - final packet = await _api.sendRequest(Opcode.authSet2fa, payload); - _checkPacketError(packet, 'update2faEmail'); - return _processProfileUpdate(packet); + return _processProfileUpdate( + _api.sendRequest(Opcode.authSet2fa, payload), + 'update2faEmail', + ); } Future remove2fa(String trackId) async { @@ -752,34 +756,46 @@ class AccountModule { 'trackId': trackId, 'remove2fa': true, }; - final packet = await _api.sendRequest(Opcode.authSet2fa, payload); - _checkPacketError(packet, 'remove2fa'); - return _processProfileUpdate(packet); + return _processProfileUpdate( + _api.sendRequest(Opcode.authSet2fa, payload), + 'remove2fa', + ); } - Future _processProfileUpdate(Packet packet) async { - _api.registerPushHandler(Opcode.notifProfile, (p) {}); - try { - await for (final push in _api.pushStream - .where((p) => p.opcode == Opcode.notifProfile) - .timeout(const Duration(seconds: 15))) { - final payload = push.payload; - if (payload is Map) { - final profile = payload['profile']; - if (profile is Map) { - final contact = profile['contact']; - if (contact is Map) { - return ProfileData.fromServerMap(contact.cast()); - } - } - } + Future _processProfileUpdate( + Future requestFuture, + String tag, + ) async { + final completer = Completer(); + final sub = _api.pushStream + .where((p) => p.opcode == Opcode.notifProfile) + .listen((push) { + if (completer.isCompleted) return; + final payload = push.payload; + if (payload is! Map) return; + final profile = payload['profile']; + if (profile is! Map) return; + final contact = profile['contact']; + if (contact is! Map) return; + completer.complete( + ProfileData.fromServerMap(contact.cast()), + ); + }); + final timer = Timer(const Duration(seconds: 15), () { + if (!completer.isCompleted) { + completer.completeError( + Exception('Таймаут ожидания обновления профиля'), + ); } - } on TimeoutException { - throw Exception('Таймаут ожидания обновления профиля'); + }); + try { + final packet = await requestFuture; + _checkPacketError(packet, tag); + return await completer.future; } finally { - _api.unregisterPushHandler(Opcode.notifProfile); + timer.cancel(); + await sub.cancel(); } - throw Exception('Не удалось получить обновлённый профиль'); } Future requestCode( diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index ab6998d..895028d 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -564,18 +564,9 @@ class _TwoFactorSetupScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 16), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Введите пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Введите пароль', ), ], ); @@ -599,18 +590,9 @@ class _TwoFactorSetupScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 16), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Повторите пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Повторите пароль', ), ], ); @@ -818,18 +800,9 @@ class _TwoFactorManageScreenState extends State { style: TextStyle(color: cs.onErrorContainer), ), ), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Пароль', ), const SizedBox(height: 24), SizedBox( @@ -1035,18 +1008,9 @@ class _TwoFactorPasswordChangeScreenState style: TextStyle(color: cs.onErrorContainer), ), ), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Введите новый пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Введите новый пароль', ), const SizedBox(height: 24), Text( @@ -1216,18 +1180,9 @@ class _TwoFactorEmailChangeScreenState ), ), const SizedBox(height: 16), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Пароль', ), ] else ...[ if (_errorMessage != null) @@ -1440,18 +1395,9 @@ class _TwoFactorRemoveScreenState extends State { style: TextStyle(color: cs.onErrorContainer), ), ), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Пароль', ), const SizedBox(height: 24), SizedBox( @@ -1484,3 +1430,45 @@ class _TwoFactorRemoveScreenState extends State { ); } } + +class _PasswordField extends StatefulWidget { + final TextEditingController controller; + final String hintText; + + const _PasswordField({ + required this.controller, + required this.hintText, + }); + + @override + State<_PasswordField> createState() => _PasswordFieldState(); +} + +class _PasswordFieldState extends State<_PasswordField> { + bool _visible = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return TextField( + controller: widget.controller, + obscureText: !_visible, + decoration: InputDecoration( + hintText: widget.hintText, + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + suffixIcon: IconButton( + icon: Icon( + _visible ? Symbols.visibility_off : Symbols.visibility, + color: cs.onSurfaceVariant, + ), + onPressed: () => setState(() => _visible = !_visible), + ), + ), + ); + } +} From e7f401b01d381e8bbd6d6455c7803163de91643f Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 29 May 2026 11:21:41 +0700 Subject: [PATCH 173/323] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=B1=D0=BE=D1=81=D1=81,=20=D0=BF=D1=80=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D1=8C=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=85=D1=83=D0=B9=20=D0=BE=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 69 +++-- lib/frontend/screens/chats/chat_screen.dart | 41 ++- .../screens/profile/cloud_storage_screen.dart | 47 ++-- .../profile/password_entry_screen.dart | 266 ++++++++++-------- lib/frontend/widgets/message_bubble.dart | 118 ++++---- lib/main.dart | 41 ++- 6 files changed, 334 insertions(+), 248 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 624382f..eb5b624 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -81,7 +81,7 @@ class _ChatListScreenState extends State double _navPageAnimStart = 0; double _navPageAnimEnd = 0; - double _navDragDx = 0; + final ValueNotifier _navDragDx = ValueNotifier(0); double _navDragBaseLeft = 0; double _revealAnimBegin = 0.0; double _closeAnimBegin = 0.0; @@ -144,7 +144,6 @@ class _ChatListScreenState extends State _isSelectionMode, _shouldCollapseSearch, _selectedChats.length, - _pullRatio, _storiesDockedOpen, _storiesAnimClosing, _storiesOverscrollRevealArmed, @@ -619,7 +618,19 @@ class _ChatListScreenState extends State }); } + int? _pageChatsBaseKey; + final Map> _pageChatsCache = {}; + List _chatsForPageIndex(int pageIndex) { + final baseKey = + Object.hash(identityHashCode(_chats), identityHashCode(_folders)); + if (_pageChatsBaseKey != baseKey) { + _pageChatsBaseKey = baseKey; + _pageChatsCache.clear(); + } + final cached = _pageChatsCache[pageIndex]; + if (cached != null) return cached; + List base; if (_folders.isEmpty) { base = _chats; @@ -634,7 +645,9 @@ class _ChatListScreenState extends State final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList() ..sort((a, b) => a.favIndex!.compareTo(b.favIndex!)); final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList(); - return [...pinned, ...regular]; + final result = [...pinned, ...regular]; + _pageChatsCache[pageIndex] = result; + return result; } void _syncFolderChatScrollControllers() { @@ -947,6 +960,7 @@ class _ChatListScreenState extends State } _contactRebuildTimer?.cancel(); _storiesUi.dispose(); + _navDragDx.dispose(); super.dispose(); } @@ -955,7 +969,7 @@ class _ChatListScreenState extends State required double Function(int index) bubbleLeftForIndex, }) { if (_navDragging) { - final left = (_navDragBaseLeft + _navDragDx).clamp( + final left = (_navDragBaseLeft + _navDragDx.value).clamp( bubbleLeftForIndex(0), bubbleLeftForIndex(3), ); @@ -1343,6 +1357,7 @@ class _ChatListScreenState extends State if (hasSeparator && index == pinnedCount) { return Padding( + key: const ValueKey('pinned_divider'), padding: const EdgeInsets.symmetric(horizontal: 20), child: Divider( height: 1, @@ -1357,10 +1372,13 @@ class _ChatListScreenState extends State final isPinned = (chat.favIndex ?? 0) > 0; if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { - final secondId = chat.participants.entries - .where((entry) => entry.key != _profile?.id) - .first - .key; + int secondId = _profile?.id ?? 0; + for (final entry in chat.participants.entries) { + if (entry.key != _profile?.id) { + secondId = entry.key; + break; + } + } final name = ContactCache.get(secondId); final avatar = ContactCache.getAvatar(secondId); // ContactCache.isOfficial covers contacts loaded via opcode 32; @@ -1510,12 +1528,6 @@ class _ChatListScreenState extends State final minBubbleLeft = bubbleLeftForIndex(0); final maxBubbleLeft = bubbleLeftForIndex(3); - final bubbleLeft = _navDragging - ? (_navDragBaseLeft + _navDragDx).clamp(minBubbleLeft, maxBubbleLeft) - : leftOffset; - - final navRowT = ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); - double navInterpolatedWidth(int tabIndex, double rowT) { final rt = rowT.clamp(0.0, 3.0); final i0 = rt.floor().clamp(0, 3); @@ -1568,39 +1580,46 @@ class _ChatListScreenState extends State if (_isSelectionMode) return; _navPageAnimController.stop(); _navPageAnimController.value = 1.0; + _navDragDx.value = 0; setState(() { _navDragging = true; - _navDragDx = 0; _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); }); }, onHorizontalDragUpdate: (details) { if (!_navDragging) return; - setState(() { - _navDragDx += details.delta.dx; - }); + _navDragDx.value += details.delta.dx; }, onHorizontalDragEnd: (_) { if (!_navDragging) return; - final left = (_navDragBaseLeft + _navDragDx).clamp( + final left = (_navDragBaseLeft + _navDragDx.value).clamp( minBubbleLeft, maxBubbleLeft, ); final next = indexForBubbleLeft(left); + _navDragDx.value = 0; setState(() { _currentNavIndex = next; _navDragging = false; - _navDragDx = 0; }); }, onHorizontalDragCancel: () { if (!_navDragging) return; + _navDragDx.value = 0; setState(() { _navDragging = false; - _navDragDx = 0; }); }, - child: Stack( + child: ValueListenableBuilder( + valueListenable: _navDragDx, + builder: (context, navDragDx, _) { + final bubbleLeft = _navDragging + ? (_navDragBaseLeft + navDragDx) + .clamp(minBubbleLeft, maxBubbleLeft) + : leftOffset; + final navRowT = + ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); + return Stack( clipBehavior: Clip.hardEdge, children: [ AnimatedPositioned( @@ -1672,6 +1691,8 @@ class _ChatListScreenState extends State ), ), ], + ); + }, ), ), ), @@ -1717,7 +1738,8 @@ class _ChatListScreenState extends State width: pageW * 4, height: pageH, child: AnimatedBuilder( - animation: _navPageAnimController, + animation: Listenable.merge( + [_navPageAnimController, _navDragDx]), child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -2066,6 +2088,7 @@ class _ChatListScreenState extends State final isSelected = _selectedChats.contains(id); return InkWell( + key: ValueKey('chat_$id'), onTap: () { if (_isSelectionMode) { _toggleSelection(id); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 06467c2..b7c54d7 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -115,6 +115,7 @@ class _ChatScreenState extends State int _otherStatus = 0; int? _otherSeenTime; final ValueNotifier _headerStatusNotifier = ValueNotifier(''); + final ValueNotifier _otherReadTime = ValueNotifier(0); int _tempIdCounter = 0; late final AnimationController _attachAnim; @@ -123,6 +124,8 @@ class _ChatScreenState extends State Timer? _shimmerStartTimer; bool _historyKickedOff = false; List _messages = []; + List? _combinedItemsCache; + int? _combinedItemsKey; int _myId = 0; CachedChat? chat; @@ -183,6 +186,7 @@ class _ChatScreenState extends State chat = value.first; }); _recomputeHeaderStatus(); + _syncOtherReadTime(); } }).catchError((_) {}); @@ -395,6 +399,7 @@ class _ChatScreenState extends State } _typingTimers.clear(); _headerStatusNotifier.dispose(); + _otherReadTime.dispose(); _uploadStatus.dispose(); _attachAnim.dispose(); _messageController.dispose(); @@ -419,17 +424,28 @@ class _ChatScreenState extends State } } - String? _effectiveStatus(CachedMessage msg) { - if (msg.senderId != _myId) return null; - if (msg.status == 'sending' || msg.status == 'error') return msg.status; + int _computeOtherReadTime() { final c = chat; - if (c == null) return 'sent'; + if (c == null) return 0; int otherReadTime = 0; for (final entry in c.participants.entries) { if (entry.key != _myId && entry.value > otherReadTime) { otherReadTime = entry.value; } } + return otherReadTime; + } + + void _syncOtherReadTime() { + final t = _computeOtherReadTime(); + if (_otherReadTime.value != t) _otherReadTime.value = t; + } + + String? _effectiveStatus(CachedMessage msg) { + if (msg.senderId != _myId) return null; + if (msg.status == 'sending' || msg.status == 'error') return msg.status; + if (chat == null) return 'sent'; + final otherReadTime = _otherReadTime.value; if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read'; return 'sent'; } @@ -555,9 +571,8 @@ class _ChatScreenState extends State final c = chat; if (c == null) return; if (c.participants[userId] == mark) return; - setState(() { - c.participants[userId] = mark; - }); + c.participants[userId] = mark; + _syncOtherReadTime(); } Future _sendMessage() async { @@ -614,6 +629,7 @@ class _ChatScreenState extends State ChatsModule.refreshChats(api, [widget.chatId]).then((list) { if (!mounted || list.isEmpty) return; setState(() => chat = list.first); + _syncOtherReadTime(); }), ); } @@ -708,6 +724,10 @@ class _ChatScreenState extends State } List _buildCombinedItems() { + final key = Object.hashAll(_messages.map(identityHashCode)); + final cached = _combinedItemsCache; + if (cached != null && _combinedItemsKey == key) return cached; + final List items = []; final Set usedDates = {}; @@ -740,6 +760,8 @@ class _ChatScreenState extends State } _separatorKeys.removeWhere((k, _) => !usedDates.contains(k)); + _combinedItemsCache = items; + _combinedItemsKey = key; return items; } @@ -1022,7 +1044,9 @@ class _ChatScreenState extends State return Stack( key: _listKey, children: [ - ValueListenableBuilder( + ValueListenableBuilder( + valueListenable: _otherReadTime, + builder: (context, _, _) => ValueListenableBuilder( valueListenable: AppCacheExtent.current, builder: (context, cacheExtent, _) => ListView.builder( controller: _scrollController, @@ -1082,6 +1106,7 @@ class _ChatScreenState extends State }, ), ), + ), Positioned( top: 8, left: 0, diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index 1f353b0..a08b595 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -43,7 +43,7 @@ class _CloudStorageScreenState extends State int? _accountId; List _files = []; bool _isUploading = false; - double _uploadProgress = 0; + final ValueNotifier _uploadProgress = ValueNotifier(0); bool _animateNewCard = false; @override @@ -68,16 +68,19 @@ class _CloudStorageScreenState extends State } mgr.onProgress = (progress, _) { if (!mounted) return; - setState(() { _isUploading = true; _uploadProgress = progress; }); + if (!_isUploading) setState(() => _isUploading = true); + _uploadProgress.value = progress; }; mgr.onDone = (file) { if (!mounted) return; - setState(() { _isUploading = false; _uploadProgress = 0; }); + _uploadProgress.value = 0; + setState(() => _isUploading = false); _prependFile(file); }; mgr.onError = (msg) { if (!mounted) return; - setState(() { _isUploading = false; _uploadProgress = 0; }); + _uploadProgress.value = 0; + setState(() => _isUploading = false); showCustomNotification(context, 'Ошибка: $msg'); }; } @@ -91,6 +94,7 @@ class _CloudStorageScreenState extends State _mode.dispose(); _pageController.dispose(); _currentFilePage.dispose(); + _uploadProgress.dispose(); super.dispose(); } @@ -218,7 +222,8 @@ class _CloudStorageScreenState extends State final picked = result.files.first; if (picked.path == null) return; - setState(() { _isUploading = true; _uploadProgress = 0; }); + _uploadProgress.value = 0; + setState(() => _isUploading = true); await UploadManager.instance.start( chatId: chatId, @@ -427,17 +432,27 @@ class _CloudStorageScreenState extends State const SizedBox(height: 16), ], if (_isUploading) ...[ - LinearProgressIndicator( - value: _uploadProgress, - borderRadius: BorderRadius.circular(4), - minHeight: 5, - color: cs.primary, - backgroundColor: cs.surfaceContainerHighest, - ), - const SizedBox(height: 8), - Text( - 'Загрузка ${(_uploadProgress * 100).toStringAsFixed(0)}%', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ValueListenableBuilder( + valueListenable: _uploadProgress, + builder: (context, progress, _) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LinearProgressIndicator( + value: progress, + borderRadius: BorderRadius.circular(4), + minHeight: 5, + color: cs.primary, + backgroundColor: cs.surfaceContainerHighest, + ), + const SizedBox(height: 8), + Text( + 'Загрузка ${(progress * 100).toStringAsFixed(0)}%', + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), ), ] else if (_files.isEmpty) ...[ Text( diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index 895028d..f127133 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -307,33 +307,40 @@ class TwoFactorSetupScreen extends StatefulWidget { class _TwoFactorSetupScreenState extends State { final _passwordController = TextEditingController(); + final _confirmController = TextEditingController(); final _hintController = TextEditingController(); final _emailController = TextEditingController(); final _codeController = TextEditingController(); int _step = 0; - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _trackId; String? _errorMessage; @override void dispose() { _passwordController.dispose(); + _confirmController.dispose(); _hintController.dispose(); _emailController.dispose(); _codeController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _nextStep() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { switch (_step) { case 0: + if (_passwordController.text.length < 6) { + setState( + () => _errorMessage = 'Пароль должен быть минимум 6 символов', + ); + break; + } final trackId = await accountModule.create2faTrack(); setState(() { _trackId = trackId; @@ -341,10 +348,8 @@ class _TwoFactorSetupScreenState extends State { }); break; case 1: - if (_passwordController.text.length < 6) { - setState( - () => _errorMessage = 'Пароль должен быть минимум 6 символов', - ); + if (_confirmController.text != _passwordController.text) { + setState(() => _errorMessage = 'Пароли не совпадают'); break; } await accountModule.set2faPassword( @@ -392,7 +397,7 @@ class _TwoFactorSetupScreenState extends State { setState(() => _errorMessage = e.toString()); } finally { if (mounted) { - setState(() => _isLoading = false); + _isLoading.value = false; } } } @@ -458,26 +463,29 @@ class _TwoFactorSetupScreenState extends State { const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _nextStep, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), ), ), ], @@ -591,7 +599,7 @@ class _TwoFactorSetupScreenState extends State { ), const SizedBox(height: 16), _PasswordField( - controller: _passwordController, + controller: _confirmController, hintText: 'Повторите пароль', ), ], @@ -714,7 +722,7 @@ class TwoFactorManageScreen extends StatefulWidget { class _TwoFactorManageScreenState extends State { final _passwordController = TextEditingController(); - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); bool _isAuthenticated = false; String? _trackId; TwoFactorDetails? _details; @@ -723,14 +731,13 @@ class _TwoFactorManageScreenState extends State { @override void dispose() { _passwordController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _authenticate() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { _trackId = await accountModule.enter2faPanel(); @@ -743,7 +750,7 @@ class _TwoFactorManageScreenState extends State { } catch (e) { setState(() => _errorMessage = 'Неверный пароль'); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -807,26 +814,29 @@ class _TwoFactorManageScreenState extends State { const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _authenticate, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _authenticate, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Продолжить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : const Text('Продолжить'), ), ), ], @@ -917,13 +927,14 @@ class _TwoFactorPasswordChangeScreenState extends State { final _passwordController = TextEditingController(); final _hintController = TextEditingController(); - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _errorMessage; @override void dispose() { _passwordController.dispose(); _hintController.dispose(); + _isLoading.dispose(); super.dispose(); } @@ -933,10 +944,8 @@ class _TwoFactorPasswordChangeScreenState return; } - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { final trackId = await accountModule.enter2faPanel(); @@ -956,7 +965,7 @@ class _TwoFactorPasswordChangeScreenState } catch (e) { setState(() => _errorMessage = e.toString()); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -1037,26 +1046,29 @@ class _TwoFactorPasswordChangeScreenState const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _changePassword, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _changePassword, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Сохранить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : const Text('Сохранить'), ), ), ], @@ -1080,7 +1092,7 @@ class _TwoFactorEmailChangeScreenState final _emailController = TextEditingController(); final _codeController = TextEditingController(); int _step = 0; - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _trackId; String? _errorMessage; @@ -1089,14 +1101,13 @@ class _TwoFactorEmailChangeScreenState _passwordController.dispose(); _emailController.dispose(); _codeController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _nextStep() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { switch (_step) { @@ -1140,7 +1151,7 @@ class _TwoFactorEmailChangeScreenState } catch (e) { setState(() => _errorMessage = e.toString()); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -1256,26 +1267,29 @@ class _TwoFactorEmailChangeScreenState const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _nextStep, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), ), ), ], @@ -1294,20 +1308,19 @@ class TwoFactorRemoveScreen extends StatefulWidget { class _TwoFactorRemoveScreenState extends State { final _passwordController = TextEditingController(); - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _errorMessage; @override void dispose() { _passwordController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _remove2fa() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { final trackId = await accountModule.enter2faPanel(); @@ -1323,7 +1336,7 @@ class _TwoFactorRemoveScreenState extends State { } catch (e) { setState(() => _errorMessage = e.toString()); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -1402,26 +1415,29 @@ class _TwoFactorRemoveScreenState extends State { const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _remove2fa, - style: FilledButton.styleFrom( - backgroundColor: cs.error, - foregroundColor: cs.onError, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _remove2fa, + style: FilledButton.styleFrom( + backgroundColor: cs.error, + foregroundColor: cs.onError, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onError, + ), + ) + : const Text('Удалить пароль'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onError, - ), - ) - : const Text('Удалить пароль'), ), ), ], diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index ca642c6..e3c4478 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -50,6 +50,10 @@ class MessageBubble extends StatelessWidget { static const Radius _smallRadius = Radius.circular(4); static const Radius _photoRadius = Radius.circular(photoBorderRadius); + static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); + static const BorderRadius _reactionChipRadius = + BorderRadius.all(Radius.circular(10)); + static Color bubbleTextColor(BuildContext context) => Theme.of(context).brightness == Brightness.dark ? Colors.white @@ -271,14 +275,30 @@ class MessageBubble extends StatelessWidget { final showAvatarSlot = !isMe; final showAvatar = showAvatarSlot && chatType == "CHAT" && - nextMessage?.senderId != message.senderId && - prevMessage?.senderId == message.senderId; + nextMessage?.senderId != message.senderId; - final bubbleListenable = Listenable.merge([ - AppBubbleShape.current, - AppBubbleBehavior.current, - ?reactionsListenable, - ]); + final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75; + final bubbleColor = + isMe ? cs.primaryContainer : cs.surfaceContainerHighest; + + _BubbleCtx makeCtx() => _BubbleCtx( + context: context, + cs: cs, + text: textColor, + shape: shape, + contentType: contentType, + hasPhotoWithCaption: hasPhotoCap, + hasMultiplePhotosNoCaption: hasMultiPhotos, + reactionInfo: _resolveReactionInfo(), + ); + + final Widget bubbleContent = + reactionsListenable != null && contentType == MessageType.text + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, _, _) => _buildContent(makeCtx()), + ) + : _buildContent(makeCtx()); return GestureDetector( onTap: Haptics.tap, @@ -310,63 +330,35 @@ class MessageBubble extends StatelessWidget { isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ ListenableBuilder( - listenable: bubbleListenable, - builder: (context, _) { - final reactionInfo = _resolveReactionInfo(); - final ctx = _BubbleCtx( - context: context, - cs: cs, - text: textColor, - shape: shape, - contentType: contentType, - hasPhotoWithCaption: hasPhotoCap, - hasMultiplePhotosNoCaption: hasMultiPhotos, - reactionInfo: reactionInfo, - ); - return AnimatedSize( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - alignment: isMe - ? Alignment.bottomRight - : Alignment.bottomLeft, - child: Container( - constraints: BoxConstraints( - maxWidth: - MediaQuery.sizeOf(context).width * 0.75, - ), - decoration: BoxDecoration( - color: isMe - ? cs.primaryContainer - : cs.surfaceContainerHighest, - borderRadius: _borderRadiusFor( - AppBubbleShape.current.value, - AppBubbleBehavior.current.value, - shape, - hasPhotoCap, - hasMultiPhotos, - ), - ), - padding: padding, - child: _buildContent(ctx), + listenable: Listenable.merge([ + AppBubbleShape.current, + AppBubbleBehavior.current, + ]), + builder: (context, child) => Container( + constraints: BoxConstraints(maxWidth: maxBubbleWidth), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: _borderRadiusFor( + AppBubbleShape.current.value, + AppBubbleBehavior.current.value, + shape, + hasPhotoCap, + hasMultiPhotos, ), - ); - }, + ), + padding: padding, + child: child, + ), + child: bubbleContent, ), if (contentType != MessageType.text) - AnimatedSize( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - alignment: isMe - ? Alignment.centerRight - : Alignment.centerLeft, - child: reactionsListenable != null - ? ValueListenableBuilder?>( - valueListenable: reactionsListenable!, - builder: (context, info, _) => - _buildReactionsBarFor(cs, info), - ) - : _buildReactionsBar(cs), - ), + reactionsListenable != null + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, info, _) => + _buildReactionsBarFor(cs, info), + ) + : _buildReactionsBar(cs), ], ), ], @@ -432,8 +424,8 @@ class MessageBubble extends StatelessWidget { decoration: BoxDecoration( color: isYours ? cs.primary.withValues(alpha: 0.22) - : Colors.black.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(10), + : _reactionChipBg, + borderRadius: _reactionChipRadius, ), child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/main.dart b/lib/main.dart index 9f85669..b007b7e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -66,18 +66,33 @@ void main() async { } attachInfoCacheApi(api); ChatsModule.attachGlobalPushHandlers(api); + + final packageInfoFuture = PackageInfo.fromPlatform(); + final localeFuture = _loadInitialLocale(); + final hapticsFuture = Haptics.load(); + final prefsFuture = SharedPreferences.getInstance(); + final accentFuture = AppAccent.load(); + final bubbleShapeFuture = AppBubbleShape.load(); + final bubbleBehaviorFuture = AppBubbleBehavior.load(); + final cacheExtentFuture = AppCacheExtent.load(); + final themeModeFuture = AppThemeModeConfig.load(); + final amoledFuture = AppAmoled.load(); + final themeScheduleFuture = AppThemeSchedule.load(); + final messageActionsFuture = AppMessageActionsStyle.load(); + final swipeBackFuture = AppSwipeBackDesktop.load(); + await api.connect(); - final packageInfo = await PackageInfo.fromPlatform(); + final packageInfo = await packageInfoFuture; if (packageInfo.packageName == 'ru.oneme.app') { await PushService.instance.init(api: api, account: accountModule); } - final initialLocale = await _loadInitialLocale(); + final initialLocale = await localeFuture; - await Haptics.load(); + await hapticsFuture; - final prefs = await SharedPreferences.getInstance(); + final prefs = await prefsFuture; await FileHistoryCache.load(prefs); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false; @@ -87,15 +102,15 @@ void main() async { final initialFontScale = AppFonts.clampScale( prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale, ); - final initialAccentSeed = await AppAccent.load(); - AppBubbleShape.current.value = await AppBubbleShape.load(); - AppBubbleBehavior.current.value = await AppBubbleBehavior.load(); - AppCacheExtent.current.value = await AppCacheExtent.load(); - AppThemeModeConfig.current.value = await AppThemeModeConfig.load(); - AppAmoled.current.value = await AppAmoled.load(); - AppThemeSchedule.current.value = await AppThemeSchedule.load(); - AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load(); - AppSwipeBackDesktop.current.value = await AppSwipeBackDesktop.load(); + final initialAccentSeed = await accentFuture; + AppBubbleShape.current.value = await bubbleShapeFuture; + AppBubbleBehavior.current.value = await bubbleBehaviorFuture; + AppCacheExtent.current.value = await cacheExtentFuture; + AppThemeModeConfig.current.value = await themeModeFuture; + AppAmoled.current.value = await amoledFuture; + AppThemeSchedule.current.value = await themeScheduleFuture; + AppMessageActionsStyle.current.value = await messageActionsFuture; + AppSwipeBackDesktop.current.value = await swipeBackFuture; runApp( KometApp( initialLocale: initialLocale, From 5a3890c55e6162b27206ab17a86dea0ee218f68f Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 29 May 2026 13:52:13 +0700 Subject: [PATCH 174/323] =?UTF-8?q?=D0=BD=D0=B0=D0=B1=D1=83=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D0=BB=D0=B4=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 14 ++ lib/core/storage/token_storage.dart | 5 + lib/frontend/screens/auth/login_screen.dart | 30 ++- .../screens/chats/chat_list_screen.dart | 58 +---- lib/frontend/screens/chats/chat_screen.dart | 19 +- .../screens/profile/debug_menu_screen.dart | 68 ++++++ .../widgets/login_success_screen.dart | 199 ++++++++++++++---- lib/frontend/widgets/message_bubble.dart | 64 ++++-- lib/main.dart | 20 +- 9 files changed, 369 insertions(+), 108 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 75449dd..74d6a64 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -937,6 +937,20 @@ class AccountModule { _checkPacketError(packet, 'authorizeWebQrLogin'); } + Future beginAddAccount() async { + try { + await _api.disconnect(); + } catch (_) {} + + await TokenStorage.clearActiveAccount(); + + ContactCache.clear(); + TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); + + logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен'); + } + Future switchAccount(int accountId) async { final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index b04ed77..2d4f600 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -24,6 +24,11 @@ class TokenStorage { await prefs.setString(_activeAccountKey, accountId.toString()); } + static Future clearActiveAccount() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_activeAccountKey); + } + static Future getActiveAccountId() async { final prefs = await SharedPreferences.getInstance(); final val = prefs.getString(_activeAccountKey); diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 037a5c8..aca0e81 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -14,10 +14,14 @@ import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; import '../profile/spoof_screen.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/adaptive_shell.dart'; +import '../../../backend/api.dart'; import '../../../main.dart'; class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); + final int? returnToAccountId; + + const LoginScreen({super.key, this.returnToAccountId}); @override State createState() => _LoginScreenState(); @@ -34,11 +38,30 @@ class _LoginScreenState extends State { @override void initState() { super.initState(); + if (api.state == SessionState.disconnected) { + unawaited(api.connect()); + } _selectedCountry = countriesByCode['RU'] ?? allCountries.first; _clampCountryToAllowed(); _checkTOS(); } + Future _onBackPressed() async { + final returnId = widget.returnToAccountId; + if (returnId != null) { + try { + await accountModule.switchAccount(returnId); + } catch (_) {} + if (!mounted) return; + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const AdaptiveShell()), + (route) => false, + ); + return; + } + if (Navigator.canPop(context)) Navigator.pop(context); + } + void _clampCountryToAllowed() { final allowed = api.registrationCountries; if (allowed.any((c) => c.code == _selectedCountry.code)) return; @@ -666,9 +689,10 @@ class _LoginScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (Navigator.canPop(context)) + if (Navigator.canPop(context) || + widget.returnToAccountId != null) IconButton( - onPressed: () => Navigator.pop(context), + onPressed: _onBackPressed, icon: Icon( Symbols.arrow_back, color: cs.onSurfaceVariant, diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index eb5b624..4a3e97a 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -25,6 +25,7 @@ import '../../../backend/modules/chats.dart'; import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/storage/token_storage.dart'; import '../../../main.dart' show accountModule, api, messagesModule; class _StoriesScrollPhysics extends BouncingScrollPhysics { @@ -100,7 +101,6 @@ class _ChatListScreenState extends State bool _navDragging = false; bool _isFabOpen = false; - bool _showCacheWarning = false; bool _storiesAnimClosing = false; Timer? _contactRebuildTimer; bool get _isSelectionMode => _selectedChats.isNotEmpty; @@ -140,7 +140,6 @@ class _ChatListScreenState extends State _selectedFolderId, _isInitialLoading, _foldersListKnown, - _showCacheWarning, _isSelectionMode, _shouldCollapseSearch, _selectedChats.length, @@ -446,12 +445,6 @@ class _ChatListScreenState extends State if (mounted) { setState(() { _sessionState = state; - if (state == SessionState.disconnected && _chats.isNotEmpty) { - _showCacheWarning = true; - } - if (state == SessionState.online) { - _showCacheWarning = false; - } }); if (state == SessionState.online) { _reloadChatsAndFolders(); @@ -1145,42 +1138,6 @@ class _ChatListScreenState extends State ), ), ), - if (_showCacheWarning) - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - color: cs.errorContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: cs.error.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - Icon( - Symbols.cloud_off, - size: 18, - color: cs.error, - ), - const SizedBox(width: 12), - const Expanded( - child: Text( - 'Ошибка соединения, сейчас вы смотрите КЕШ', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ), Padding( padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), child: Container( @@ -2393,9 +2350,16 @@ class _ChatListScreenState extends State controller.dispose(); if (!mounted) return; if (accountId == null) { - await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const LoginScreen()), + final previousId = await TokenStorage.getActiveAccountId(); + try { + await accountModule.beginAddAccount(); + } catch (_) {} + if (!mounted) return; + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => LoginScreen(returnToAccountId: previousId), + ), + (route) => false, ); return; } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index b7c54d7..31ddbef 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -114,6 +114,7 @@ class _ChatScreenState extends State final Map _typingTimers = {}; int _otherStatus = 0; int? _otherSeenTime; + int? _participantsCount; final ValueNotifier _headerStatusNotifier = ValueNotifier(''); final ValueNotifier _otherReadTime = ValueNotifier(0); int _tempIdCounter = 0; @@ -172,9 +173,21 @@ class _ChatScreenState extends State ); unawaited(_fastPreloadCache()); + unawaited(_loadParticipantsCount()); WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); } + Future _loadParticipantsCount() async { + if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return; + final info = await ChatsModule.getChatInfo(api, widget.chatId); + if (!mounted) return; + final count = info?['participantsCount'] as int?; + if (count != null && count != _participantsCount) { + _participantsCount = count; + _recomputeHeaderStatus(); + } + } + Future _fastPreloadCache() async { final p = await AppDatabase.loadActiveProfile(); if (!mounted) return; @@ -521,10 +534,12 @@ class _ChatScreenState extends State String _headerStatus() { if (_typingUserIds.isNotEmpty) return 'Печатает...'; if (widget.chatType == 'CHAT') { - return '${chat?.participants.length ?? 0} участников'; + final count = _participantsCount ?? chat?.participants.length ?? 0; + return '$count участников'; } if (widget.chatType == 'CHANNEL') { - return '${chat?.participants.length ?? 0} подписчиков'; + final count = _participantsCount ?? chat?.participants.length ?? 0; + return '$count подписчиков'; } if (_otherStatus == 1) return 'В сети'; if (_otherStatus == 3) return 'Был(-а) недавно'; diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index b607079..3d409a7 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -9,6 +9,7 @@ import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/login_success_screen.dart'; class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); @@ -397,6 +398,73 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const LoginSuccessScreen(preview: true), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.celebration, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'test hello', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Показать приветственную анимацию входа', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index 9617e37..022a8bb 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -23,7 +23,9 @@ Future precacheLoginAvatar( class LoginSuccessScreen extends StatefulWidget { final ImageProvider? avatar; - const LoginSuccessScreen({super.key, this.avatar}); + final bool preview; + + const LoginSuccessScreen({super.key, this.avatar, this.preview = false}); @override State createState() => _LoginSuccessScreenState(); @@ -54,6 +56,8 @@ class _LoginSuccessScreenState extends State late final Animation _fadeOut; bool _navigated = false; + bool _handedOff = false; + final GlobalKey _subtitleKey = GlobalKey(); @override void initState() { @@ -114,26 +118,66 @@ class _LoginSuccessScreenState extends State void _onStatus(AnimationStatus status) { if (status == AnimationStatus.completed && !_navigated && mounted) { _navigated = true; - Navigator.of(context).pushAndRemoveUntil( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 360), - reverseTransitionDuration: const Duration(milliseconds: 200), - pageBuilder: (_, __, ___) => const AdaptiveShell(), - transitionsBuilder: (_, animation, __, child) { - return FadeTransition( - opacity: CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ), - child: child, - ); - }, - ), - (route) => false, - ); + + final navigator = Navigator.of(context); + final overlay = navigator.overlay; + final entry = _buildGreetingEntry(); + if (entry != null) { + setState(() => _handedOff = true); + } + + if (widget.preview) { + navigator.pop(); + } else { + navigator.pushAndRemoveUntil( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 360), + reverseTransitionDuration: const Duration(milliseconds: 200), + pageBuilder: (_, _, _) => const AdaptiveShell(), + transitionsBuilder: (_, animation, _, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + child: child, + ); + }, + ), + (route) => false, + ); + } + + if (entry != null && overlay != null) { + overlay.insert(entry); + } } } + OverlayEntry? _buildGreetingEntry() { + final box = _subtitleKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null || !box.attached) return null; + + final topLeft = box.localToGlobal(Offset.zero); + final size = box.size; + final color = Theme.of(context).colorScheme.onSurfaceVariant; + + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (_) => Positioned( + left: topLeft.dx, + top: topLeft.dy, + width: size.width, + child: _GreetingLinger( + greeting: _greeting, + color: color, + onDone: () => entry.remove(), + ), + ), + ); + return entry; + } + @override void dispose() { _controller.removeStatusListener(_onStatus); @@ -149,11 +193,12 @@ class _LoginSuccessScreenState extends State body: AnimatedBuilder( animation: _controller, builder: (context, _) { - return Opacity( - opacity: 1.0 - _fadeOut.value, - child: Stack( - children: [ - Positioned.fill( + final celebrationOpacity = 1.0 - _fadeOut.value; + return Stack( + children: [ + Positioned.fill( + child: Opacity( + opacity: celebrationOpacity, child: DecoratedBox( decoration: BoxDecoration( gradient: RadialGradient( @@ -169,11 +214,14 @@ class _LoginSuccessScreenState extends State ), ), ), - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( + ), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Opacity( + opacity: celebrationOpacity, + child: SizedBox( width: 220, height: 220, child: Stack( @@ -186,15 +234,21 @@ class _LoginSuccessScreenState extends State ], ), ), - const SizedBox(height: 28), - _buildTitle(cs), - const SizedBox(height: 8), - _buildSubtitle(cs), - ], - ), + ), + const SizedBox(height: 28), + Opacity( + opacity: celebrationOpacity, + child: _buildTitle(cs), + ), + const SizedBox(height: 8), + Opacity( + opacity: _handedOff ? 0.0 : 1.0, + child: _buildSubtitle(cs), + ), + ], ), - ], - ), + ), + ], ); }, ), @@ -315,6 +369,7 @@ class _LoginSuccessScreenState extends State padding: const EdgeInsets.symmetric(horizontal: 40), child: Text( _greeting, + key: _subtitleKey, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurfaceVariant, @@ -329,6 +384,78 @@ class _LoginSuccessScreenState extends State } } +class _GreetingLinger extends StatefulWidget { + final String greeting; + final Color color; + final VoidCallback onDone; + + const _GreetingLinger({ + required this.greeting, + required this.color, + required this.onDone, + }); + + @override + State<_GreetingLinger> createState() => _GreetingLingerState(); +} + +class _GreetingLingerState extends State<_GreetingLinger> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + _opacity = TweenSequence([ + TweenSequenceItem(tween: ConstantTween(1.0), weight: 55), + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 0.0) + .chain(CurveTween(curve: Curves.easeInCubic)), + weight: 45, + ), + ]).animate(_controller); + _controller.addStatusListener((status) { + if (status == AnimationStatus.completed) widget.onDone(); + }); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: AnimatedBuilder( + animation: _opacity, + builder: (context, _) { + return Opacity( + opacity: _opacity.value, + child: Text( + widget.greeting, + textAlign: TextAlign.center, + style: TextStyle( + color: widget.color, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.3, + ), + ), + ); + }, + ), + ); + } +} + class _CheckPainter extends CustomPainter { final double progress; final Color color; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index e3c4478..3efeaae 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -300,6 +300,10 @@ class MessageBubble extends StatelessWidget { ) : _buildContent(makeCtx()); + final reactionsUnder = _reactionsUnderBubble(contentType); + final reactionsInside = + contentType != MessageType.text && !reactionsUnder; + return GestureDetector( onTap: Haptics.tap, child: Padding( @@ -349,16 +353,15 @@ class MessageBubble extends StatelessWidget { padding: padding, child: child, ), - child: bubbleContent, - ), - if (contentType != MessageType.text) - reactionsListenable != null - ? ValueListenableBuilder?>( - valueListenable: reactionsListenable!, - builder: (context, info, _) => - _buildReactionsBarFor(cs, info), + child: reactionsInside + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [bubbleContent, _reactionsBar(cs)], ) - : _buildReactionsBar(cs), + : bubbleContent, + ), + if (reactionsUnder) _reactionsBar(cs), ], ), ], @@ -378,6 +381,27 @@ class MessageBubble extends StatelessWidget { return null; } + bool _reactionsUnderBubble(MessageType contentType) { + if (contentType != MessageType.attachment) return false; + final attachments = message.attachments; + if (attachments == null || attachments.isEmpty) return false; + if (attachments.first is ForwardedMessageAttachment) return false; + if (attachments.any((a) => a is ContactAttachment)) return false; + if (attachments.whereType().length >= 2) return false; + return true; + } + + Widget _reactionsBar(ColorScheme cs) { + final listenable = reactionsListenable; + if (listenable != null) { + return ValueListenableBuilder?>( + valueListenable: listenable, + builder: (context, info, _) => _buildReactionsBarFor(cs, info), + ); + } + return _buildReactionsBar(cs); + } + Widget _buildContent(_BubbleCtx ctx) { switch (ctx.contentType) { case MessageType.control: @@ -1752,10 +1776,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { @override void initState() { super.initState(); - if (widget.preloadedText != null) { - _transcriptionText = widget.preloadedText; - _transcriptionVisible = true; - } + _transcriptionText = widget.preloadedText; } String _formatDuration(int seconds) { @@ -1917,14 +1938,19 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - _formatDuration(widget.duration), - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.7), - fontSize: 11, + SizedBox( + width: 32, + child: Center( + child: Text( + _formatDuration(widget.duration), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.7), + fontSize: 11, + ), + ), ), ), - const SizedBox(width: 8), + const SizedBox(width: 10), Expanded( child: AnimatedSize( duration: const Duration(milliseconds: 200), diff --git a/lib/main.dart b/lib/main.dart index b007b7e..529d616 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -700,8 +700,13 @@ class _StartupScreenState extends State<_StartupScreen> { } Future _tryAutoLogin() async { - final accountId = await TokenStorage.getActiveAccountId(); + int? accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null || await TokenStorage.readToken(accountId) == null) { + accountId = await _recoverActiveAccount(); + } + + if (accountId == null) { _goToLogin(); return; } @@ -718,6 +723,19 @@ class _StartupScreenState extends State<_StartupScreen> { } } + Future _recoverActiveAccount() async { + final profiles = await AppDatabase.loadAllProfiles(); + for (final profile in profiles) { + if (await TokenStorage.readToken(profile.id) != null) { + await TokenStorage.setActiveAccount(profile.id); + await AppDatabase.setActiveAccount(profile.id); + await ContactsModule.primeCacheFromDb(profile.id); + return profile.id; + } + } + return null; + } + void _goToLogin() { if (mounted) { Navigator.pushReplacement( From acff45b7dd4c911ca9acb637a370c8a38336e37e Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 29 May 2026 17:46:24 +0700 Subject: [PATCH 175/323] =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D0=B4=D0=B5=D0=BF=D0=BE=D0=BB=D0=B0=20=D1=81=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BF=D0=BB=D0=B0=D1=88=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/login_success_screen.dart | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index 022a8bb..9cc9d93 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -155,12 +155,24 @@ class _LoginSuccessScreenState extends State } OverlayEntry? _buildGreetingEntry() { - final box = _subtitleKey.currentContext?.findRenderObject() as RenderBox?; + final subtitleContext = _subtitleKey.currentContext; + if (subtitleContext == null) return null; + final box = subtitleContext.findRenderObject() as RenderBox?; if (box == null || !box.attached) return null; final topLeft = box.localToGlobal(Offset.zero); final size = box.size; - final color = Theme.of(context).colorScheme.onSurfaceVariant; + final style = DefaultTextStyle.of(subtitleContext) + .style + .merge(const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.3, + )) + .copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + decoration: TextDecoration.none, + ); late final OverlayEntry entry; entry = OverlayEntry( @@ -170,7 +182,7 @@ class _LoginSuccessScreenState extends State width: size.width, child: _GreetingLinger( greeting: _greeting, - color: color, + style: style, onDone: () => entry.remove(), ), ), @@ -386,12 +398,12 @@ class _LoginSuccessScreenState extends State class _GreetingLinger extends StatefulWidget { final String greeting; - final Color color; + final TextStyle style; final VoidCallback onDone; const _GreetingLinger({ required this.greeting, - required this.color, + required this.style, required this.onDone, }); @@ -434,23 +446,21 @@ class _GreetingLingerState extends State<_GreetingLinger> @override Widget build(BuildContext context) { return IgnorePointer( - child: AnimatedBuilder( - animation: _opacity, - builder: (context, _) { - return Opacity( - opacity: _opacity.value, - child: Text( - widget.greeting, - textAlign: TextAlign.center, - style: TextStyle( - color: widget.color, - fontSize: 15, - fontWeight: FontWeight.w500, - height: 1.3, + child: Material( + type: MaterialType.transparency, + child: AnimatedBuilder( + animation: _opacity, + builder: (context, _) { + return Opacity( + opacity: _opacity.value, + child: Text( + widget.greeting, + textAlign: TextAlign.center, + style: widget.style, ), - ), - ); - }, + ); + }, + ), ), ); } From 838e892bbc897732d8c754192c600db0fad75c31 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 29 May 2026 18:38:33 +0700 Subject: [PATCH 176/323] =?UTF-8?q?=D0=BE=D0=BD=D1=8D=D0=BC=D0=B0=D1=88?= =?UTF-8?q?=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_pranks.dart | 20 ++ .../screens/chats/chat_list_screen.dart | 52 ++++- lib/frontend/screens/chats/chat_screen.dart | 177 +++++++++++++++--- .../screens/profile/debug_menu_screen.dart | 55 ++++++ lib/main.dart | 6 + 5 files changed, 284 insertions(+), 26 deletions(-) create mode 100644 lib/core/config/app_pranks.dart diff --git a/lib/core/config/app_pranks.dart b/lib/core/config/app_pranks.dart new file mode 100644 index 0000000..033a23a --- /dev/null +++ b/lib/core/config/app_pranks.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppPranks { + static const prefKey = 'dev_pranks'; + static const bool defaultValue = false; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? defaultValue; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 4a3e97a..6790abd 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -26,7 +26,8 @@ import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; -import '../../../main.dart' show accountModule, api, messagesModule; +import '../../../main.dart' + show accountModule, api, messagesModule, appRouteObserver; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -73,7 +74,7 @@ class ChatListScreen extends StatefulWidget { enum _DeleteKind { personalLike, ownerGroup, blocked } class _ChatListScreenState extends State - with TickerProviderStateMixin { + with TickerProviderStateMixin, RouteAware { String? _selectedFolderId; List _folders = []; @@ -103,6 +104,9 @@ class _ChatListScreenState extends State bool _isFabOpen = false; bool _storiesAnimClosing = false; Timer? _contactRebuildTimer; + bool _deferReloads = false; + bool _reloadQueued = false; + Timer? _settleTimer; bool get _isSelectionMode => _selectedChats.isNotEmpty; bool? _foldersListKnown; @@ -447,22 +451,58 @@ class _ChatListScreenState extends State _sessionState = state; }); if (state == SessionState.online) { - _reloadChatsAndFolders(); + _requestReload(); } } }); _loginSub = accountModule.loginStatusStream.listen((status) { if (status == LoginStatus.success) { - _reloadChatsAndFolders(); + _requestReload(); } }); ChatsModule.chatsChanged.addListener(_onChatsChanged); _reloadChatsAndFolders(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute) { + appRouteObserver.subscribe(this, route); + } + } + + @override + void didPushNext() { + _deferReloads = true; + } + + @override + void didPopNext() { + _settleTimer?.cancel(); + _settleTimer = Timer(const Duration(milliseconds: 420), () { + if (!mounted) return; + _deferReloads = false; + if (_reloadQueued) { + _reloadQueued = false; + _reloadChatsAndFolders(); + } + }); + } + + void _requestReload() { + if (!mounted) return; + if (_deferReloads) { + _reloadQueued = true; + return; + } + _reloadChatsAndFolders(); + } + void _onChatsChanged() { - if (mounted) _reloadChatsAndFolders(); + _requestReload(); } Future _reloadChatsAndFolders() async { @@ -934,6 +974,8 @@ class _ChatListScreenState extends State @override void dispose() { + appRouteObserver.unsubscribe(this); + _settleTimer?.cancel(); ChatsModule.chatsChanged.removeListener(_onChatsChanged); _loginSub?.cancel(); _stateSub?.cancel(); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 31ddbef..41e0cbe 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io' show File; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; @@ -22,8 +23,10 @@ import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; +import '../../../core/config/app_pranks.dart'; import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; +import '../../widgets/theme_reveal.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; import '../../widgets/swipe_to_pop.dart'; @@ -115,6 +118,14 @@ class _ChatScreenState extends State int _otherStatus = 0; int? _otherSeenTime; int? _participantsCount; + + bool _prankActive = false; + String? _prankBubbleId; + final GlobalKey _prankBubbleKey = GlobalKey(); + final GlobalKey _prankCaptureKey = GlobalKey(); + OverlayEntry? _prankRevealEntry; + AnimationController? _prankRevealController; + ui.Image? _prankRevealImage; final ValueNotifier _headerStatusNotifier = ValueNotifier(''); final ValueNotifier _otherReadTime = ValueNotifier(0); int _tempIdCounter = 0; @@ -413,6 +424,7 @@ class _ChatScreenState extends State _typingTimers.clear(); _headerStatusNotifier.dispose(); _otherReadTime.dispose(); + _finishPrankReveal(); _uploadStatus.dispose(); _attachAnim.dispose(); _messageController.dispose(); @@ -454,6 +466,102 @@ class _ChatScreenState extends State if (_otherReadTime.value != t) _otherReadTime.value = t; } + void _checkPrankTrigger(CachedMessage msg) { + if (!AppPranks.current.value || _prankActive || _prankBubbleId != null) { + return; + } + if ((msg.text ?? '').trim().toUpperCase() != 'THE WORLD') return; + setState(() => _prankBubbleId = msg.id); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _runPrankReveal(); + }); + } + + ThemeData _prankPinkTheme(ThemeData base) { + final cs = base.colorScheme; + return base.copyWith( + scaffoldBackgroundColor: const Color(0xFFFFF0F5), + colorScheme: cs.copyWith( + surface: const Color(0xFFFFF0F5), + surfaceContainerHigh: const Color(0xFFFFE3EC), + surfaceContainerHighest: const Color(0xFFFFD9E6), + primary: const Color(0xFFE8579A), + primaryContainer: const Color(0xFFFFD6E5), + onPrimaryContainer: const Color(0xFF7A1F4B), + ), + ); + } + + void _runPrankReveal() { + if (_prankActive) return; + final overlay = Navigator.of(context).overlay; + final captureCtx = _prankCaptureKey.currentContext; + final renderObject = captureCtx?.findRenderObject(); + if (overlay == null || renderObject is! RenderRepaintBoundary) { + setState(() => _prankActive = true); + return; + } + + Offset center; + final bubbleBox = + _prankBubbleKey.currentContext?.findRenderObject() as RenderBox?; + if (bubbleBox != null && bubbleBox.attached) { + center = bubbleBox.localToGlobal(bubbleBox.size.center(Offset.zero)); + } else { + final size = MediaQuery.sizeOf(context); + center = Offset(size.width / 2, size.height / 2); + } + + final ui.Image snapshot; + try { + final dpr = math.min(MediaQuery.of(context).devicePixelRatio, 2.0); + snapshot = renderObject.toImageSync(pixelRatio: dpr); + } catch (_) { + setState(() => _prankActive = true); + return; + } + + _finishPrankReveal(); + + final controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 650), + ); + final entry = ThemeRevealOverlay.build( + snapshot: snapshot, + center: center, + animation: controller, + ); + + _prankRevealController = controller; + _prankRevealEntry = entry; + _prankRevealImage = snapshot; + + overlay.insert(entry); + setState(() => _prankActive = true); + Haptics.success(); + + WidgetsBinding.instance.endOfFrame.then((_) { + if (_prankRevealController != controller) return; + controller.forward().then((_) { + if (_prankRevealController != controller) return; + _finishPrankReveal(); + }, onError: (_) {}); + }); + } + + void _finishPrankReveal() { + _prankRevealEntry?.remove(); + _prankRevealEntry = null; + _prankRevealController?.dispose(); + _prankRevealController = null; + final img = _prankRevealImage; + _prankRevealImage = null; + if (img != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose()); + } + } + String? _effectiveStatus(CachedMessage msg) { if (msg.senderId != _myId) return null; if (msg.status == 'sending' || msg.status == 'error') return msg.status; @@ -486,6 +594,7 @@ class _ChatScreenState extends State _clearTyping(message.senderId); Haptics.tap(); _scrollToBottom(); + _checkPrankTrigger(message); case MessageEditedEvent(:final message): final idx = _messages.indexWhere((m) => m.id == message.id); if (idx == -1) return; @@ -615,28 +724,32 @@ class _ChatScreenState extends State _messages.add(tempMessage); _messageController.clear(); }); + unawaited(_persistOutgoing(tempMessage)); // Instant tactile "whoosh" the moment the message leaves the composer, // not after the network round-trip — feedback must feel immediate. Haptics.send(); _scrollToBottom(); + _checkPrankTrigger(tempMessage); final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { + final sent = CachedMessage( + id: actualId.isNotEmpty ? actualId : tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'sent', + ); setState(() { - _messages[index] = CachedMessage( - id: actualId.isNotEmpty ? actualId : tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: text, - time: now, - status: 'sent', - ); + _messages[index] = sent; }); + unawaited(_persistOutgoing(sent, removeId: tempId)); } if (chat == null) { @@ -652,21 +765,32 @@ class _ChatScreenState extends State Haptics.error(); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { + final failed = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'error', + ); setState(() { - _messages[index] = CachedMessage( - id: tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: text, - time: now, - status: 'error', - ); + _messages[index] = failed; }); + unawaited(_persistOutgoing(failed)); } } } + Future _persistOutgoing(CachedMessage msg, {String? removeId}) async { + try { + if (removeId != null && removeId != msg.id) { + await AppDatabase.deleteMessage(_myId, widget.chatId, removeId); + } + await AppDatabase.saveMessages([msg.toDbRow()]); + } catch (_) {} + } + Future _loadForwardedSenderNames() async { final forwardIds = {}; for (final msg in _messages) { @@ -879,11 +1003,17 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final theme = + _prankActive ? _prankPinkTheme(Theme.of(context)) : Theme.of(context); + final cs = theme.colorScheme; // TODO: Локализация // TODO: Cклонения - return ValueListenableBuilder( + return Theme( + data: theme, + child: RepaintBoundary( + key: _prankCaptureKey, + child: ValueListenableBuilder( valueListenable: AppSwipeBackDesktop.current, builder: (context, desktopSwipe, child) => SwipeToPop( enabled: widget.embedded && desktopSwipe, @@ -1039,6 +1169,8 @@ class _ChatScreenState extends State ], ), ), + ), + ), ); } @@ -1114,10 +1246,13 @@ class _ChatScreenState extends State ) : pressable; - return RepaintBoundary( + final builtItem = RepaintBoundary( key: ValueKey('msg_${message.id}'), child: child, ); + return message.id == _prankBubbleId + ? KeyedSubtree(key: _prankBubbleKey, child: builtItem) + : builtItem; }, ), ), diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 3d409a7..0fcfd9e 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; +import '../../../core/config/app_pranks.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; @@ -398,6 +399,60 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppPranks.current, + builder: (context, pranksOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.auto_awesome, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Приколь4ики', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Switch( + value: pranksOn, + onChanged: (v) { + AppPranks.save(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/main.dart b/lib/main.dart index 529d616..9f611f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; import 'core/config/app_message_actions_style.dart'; import 'core/config/app_swipe_back_desktop.dart'; +import 'core/config/app_pranks.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -43,6 +44,8 @@ final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); final fileUploader = FileUploader(api: api, messages: messagesModule); +final RouteObserver> appRouteObserver = + RouteObserver>(); Future _loadInitialLocale() async { final prefs = await SharedPreferences.getInstance(); @@ -80,6 +83,7 @@ void main() async { final themeScheduleFuture = AppThemeSchedule.load(); final messageActionsFuture = AppMessageActionsStyle.load(); final swipeBackFuture = AppSwipeBackDesktop.load(); + final pranksFuture = AppPranks.load(); await api.connect(); @@ -111,6 +115,7 @@ void main() async { AppThemeSchedule.current.value = await themeScheduleFuture; AppMessageActionsStyle.current.value = await messageActionsFuture; AppSwipeBackDesktop.current.value = await swipeBackFuture; + AppPranks.current.value = await pranksFuture; runApp( KometApp( initialLocale: initialLocale, @@ -643,6 +648,7 @@ class KometAppState extends State theme: _lightTheme, darkTheme: _darkTheme, navigatorKey: KometApp.navigatorKey, + navigatorObservers: [appRouteObserver], builder: (context, child) { return ValueListenableBuilder( valueListenable: fontScale, From 4afdf4b61e5488d8e19acb9ae745898c752857fe Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 31 May 2026 17:43:58 +0700 Subject: [PATCH 177/323] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 2 +- lib/backend/modules/file_uploader.dart | 80 ++++++++++++++----- lib/core/utils/image_utils.dart | 28 +++++++ .../screens/chats/create_group_flow.dart | 25 +++--- .../screens/profile/edit_profile_screen.dart | 56 ++++++++++++- pubspec.lock | 36 +++++++-- pubspec.yaml | 1 + 7 files changed, 185 insertions(+), 43 deletions(-) create mode 100644 lib/core/utils/image_utils.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 74d6a64..70b4b9a 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -484,7 +484,7 @@ class AccountModule { return newProfile; } - Future updateProfileAvatar(String photoToken, String avatarType) async { + Future updateProfileAvatar(String photoToken, {String avatarType = 'USER_AVATAR'}) async { _ensureOnline(); final packet = await _api.sendRequest(Opcode.profile, { 'photoToken': photoToken, diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 2aae308..3091e60 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -190,13 +190,23 @@ class FileUploader { Socket? socket; try { socket = await _openSocket(uri); + final boundary = '----KometBoundary${DateTime.now().microsecondsSinceEpoch}'; + final preamble = utf8.encode( + '--$boundary\r\n' + 'Content-Disposition: form-data; name="file"; filename="$filename"\r\n' + 'Content-Type: ${_contentTypeForFilename(filename)}\r\n' + '\r\n', + ); + final epilogue = utf8.encode('\r\n--$boundary--\r\n'); _writeImageHeaders( socket, uri, - bytes.length, - contentType: _contentTypeForFilename(filename), + preamble.length + bytes.length + epilogue.length, + boundary: boundary, ); + socket.add(preamble); socket.add(bytes); + socket.add(epilogue); await socket.flush(); final response = await _readFullResponse( @@ -208,7 +218,6 @@ class FileUploader { } catch (_) {} if (response == null) { - logger.w('uploadImage: empty/timed-out response'); return null; } final (status, body) = response; @@ -230,12 +239,12 @@ class FileUploader { } } - void _writeImageHeaders(Socket socket, Uri uri, int total, {required String contentType}) { + void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) { final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; final headers = StringBuffer() ..write('POST $path HTTP/1.1\r\n') ..write('Host: ${uri.host}\r\n') - ..write('Content-Type: $contentType\r\n') + ..write('Content-Type: multipart/form-data; boundary=$boundary\r\n') ..write('Content-Length: $total\r\n') ..write('Connection: keep-alive\r\n') ..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n') @@ -273,36 +282,63 @@ class FileUploader { Timer? timer; StreamSubscription>? sub; - void finish() { + void finishWith((int, String)? value) { timer?.cancel(); sub?.cancel(); - if (completer.isCompleted) return; + if (!completer.isCompleted) completer.complete(value); + } + + (int, String)? tryParse({required bool atClose}) { final headerEnd = _findHeaderEnd(bytes); - if (headerEnd == -1) { - completer.complete(null); - return; - } + if (headerEnd == -1) return null; final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true); final lines = headerStr.split('\r\n'); final parts = lines.first.split(' '); final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0; - final chunked = lines.skip(1).any( + final headerLines = lines.skip(1); + final chunked = headerLines.any( (l) => l.toLowerCase().startsWith('transfer-encoding:') && l.toLowerCase().contains('chunked'), ); + int? contentLength; + for (final l in headerLines) { + if (l.toLowerCase().startsWith('content-length:')) { + contentLength = int.tryParse(l.split(':').last.trim()); + } + } final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true); - final body = chunked ? _decodeChunked(rawBody) : rawBody; - completer.complete((status, body)); + if (chunked) { + if (!atClose && !rawBody.contains('\r\n0\r\n')) return null; + return (status, _decodeChunked(rawBody)); + } + if (contentLength != null && !atClose && bytes.length - headerEnd < contentLength) { + return null; + } + return (status, rawBody); } - void fail() { - timer?.cancel(); - sub?.cancel(); - if (!completer.isCompleted) completer.complete(null); - } - - sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish); - timer = Timer(timeout, fail); + sub = socket.listen( + (chunk) { + bytes.addAll(chunk); + final parsed = tryParse(atClose: false); + if (parsed != null) finishWith(parsed); + }, + onError: (e) { + logger.w('uploadImage: socket error after ${bytes.length} bytes: $e'); + finishWith(tryParse(atClose: true)); + }, + onDone: () { + final parsed = tryParse(atClose: true); + if (parsed == null) { + logger.w('uploadImage: connection closed without HTTP response (${bytes.length} bytes)'); + } + finishWith(parsed); + }, + ); + timer = Timer(timeout, () { + logger.w('uploadImage: response timeout after ${bytes.length} bytes'); + finishWith(tryParse(atClose: true)); + }); return completer.future; } diff --git a/lib/core/utils/image_utils.dart b/lib/core/utils/image_utils.dart new file mode 100644 index 0000000..a7b99a7 --- /dev/null +++ b/lib/core/utils/image_utils.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +const int _avatarMaxDimension = 1024; +const int _avatarTargetBytes = 900 * 1024; + +Future compressAvatar(Uint8List input) => compute(_encodeAvatar, input); + +Uint8List? _encodeAvatar(Uint8List input) { + final decoded = img.decodeImage(input); + if (decoded == null) return null; + final oriented = img.bakeOrientation(decoded); + final image = oriented.width > _avatarMaxDimension || oriented.height > _avatarMaxDimension + ? img.copyResize( + oriented, + width: oriented.width >= oriented.height ? _avatarMaxDimension : null, + height: oriented.height > oriented.width ? _avatarMaxDimension : null, + interpolation: img.Interpolation.average, + ) + : oriented; + var quality = 88; + var out = img.encodeJpg(image, quality: quality); + while (out.lengthInBytes > _avatarTargetBytes && quality > 35) { + quality -= 12; + out = img.encodeJpg(image, quality: quality); + } + return out; +} diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index 3f6b3ea..c35dca9 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/contacts.dart'; import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/image_utils.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/swipe_route.dart'; @@ -131,16 +132,20 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { if (_avatar != null) { final url = await ChatsModule.requestChatPhotoUploadUrl(api); if (url != null) { - final bytes = await _avatar!.readAsBytes(); - final token = await fileUploader.uploadImage( - Uri.parse(url), - bytes, - filename: _avatar!.uri.pathSegments.last, - ); - if (token != null) { - await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); - } else if (mounted) { - showCustomNotification(context, 'Не удалось загрузить аватарку'); + final bytes = await compressAvatar(await _avatar!.readAsBytes()); + if (bytes == null) { + if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку'); + } else { + final token = await fileUploader.uploadImage( + Uri.parse(url), + bytes, + filename: 'avatar.jpg', + ); + if (token != null) { + await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); + } else if (mounted) { + showCustomNotification(context, 'Не удалось загрузить аватарку'); + } } } } diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart index 44c2099..200bc49 100644 --- a/lib/frontend/screens/profile/edit_profile_screen.dart +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -1,10 +1,14 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/image_utils.dart'; import '../../../l10n/app_localizations.dart'; -import '../../../main.dart' show accountModule, KometApp; +import '../../../main.dart' show accountModule, fileUploader, KometApp; import '../../widgets/custom_notification.dart'; +const int _maxAvatarBytes = 8 * 1024 * 1024; + class EditProfileScreen extends StatefulWidget { const EditProfileScreen({super.key}); @@ -77,12 +81,56 @@ class _EditProfileScreenState extends State { Future _changeAvatar() async { if (_isSaving) return; + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + withData: true, + ); + if (result == null || result.files.isEmpty) return; + final picked = result.files.first; + final bytes = picked.bytes; + if (bytes == null) { + if (mounted) showCustomNotification(context, 'Не удалось прочитать файл'); + return; + } + if (bytes.length > _maxAvatarBytes) { + if (mounted) showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); + return; + } + if (!mounted) return; + setState(() => _isSaving = true); try { - final uploadUrl = await accountModule.getAvatarUploadUrl(); + final processed = await compressAvatar(bytes); + if (processed == null) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось обработать изображение'); + setState(() => _isSaving = false); + return; + } + final url = await accountModule.getAvatarUploadUrl(); + final token = await fileUploader.uploadImage( + Uri.parse(url), + processed, + filename: 'avatar.jpg', + ); + if (token == null) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось загрузить аватарку'); + setState(() => _isSaving = false); + return; + } + final newProfile = await accountModule.updateProfileAvatar(token); if (!mounted) return; - showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)'); + setState(() { + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + _isSaving = false; + }); + KometApp.stateOf(context)?.notifyProfileUpdate(); + showCustomNotification(context, 'Аватарка обновлена'); } catch (e) { - if (mounted) showCustomNotification(context, 'Ошибка: $e'); + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); } } diff --git a/pubspec.lock b/pubspec.lock index d2ce143..0545e63 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -413,6 +421,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1" + image: + dependency: "direct main" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" intl: dependency: "direct main" description: @@ -505,10 +521,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -537,10 +553,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -693,6 +709,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" progress_indicator_m3e: dependency: transitive description: @@ -902,10 +926,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 80f23de..6fbaeec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 file_picker: ^8.0.0 + image: ^4.3.0 sqflite: ^2.4.2 sqflite_common_ffi: ^2.4.0+2 path: ^1.9.1 From d0d99b0a8f88c80d40c30a2287ea591937b1ee79 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 2 Jun 2026 16:10:41 +0000 Subject: [PATCH 178/323] =?UTF-8?q?feat:=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D0=B5=20=D0=B4=D0=B5=D0=B2-=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8E=20=D0=BF=D0=BE=207=20=D1=82=D0=B0=D0=BF=D0=B0=D0=BC=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20=D0=B2=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/auth/login_screen.dart | 32 ++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index aca0e81..40fb36b 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -13,6 +13,7 @@ import 'select_country_screen.dart'; import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; import '../profile/spoof_screen.dart'; +import '../profile/debug_menu_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/adaptive_shell.dart'; import '../../../backend/api.dart'; @@ -34,6 +35,8 @@ class _LoginScreenState extends State { bool _isTOSRead = false; String? _phoneError; Timer? _phoneErrorTimer; + int _logoTapCount = 0; + Timer? _logoTapTimer; @override void initState() { @@ -74,6 +77,7 @@ class _LoginScreenState extends State { @override void dispose() { _phoneErrorTimer?.cancel(); + _logoTapTimer?.cancel(); _phoneController.dispose(); super.dispose(); } @@ -87,6 +91,22 @@ class _LoginScreenState extends State { } } + void _onLogoTap() { + _logoTapTimer?.cancel(); + _logoTapTimer = Timer(const Duration(milliseconds: 600), () { + _logoTapCount = 0; + }); + _logoTapCount++; + if (_logoTapCount >= 7) { + _logoTapTimer?.cancel(); + _logoTapCount = 0; + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const DebugMenuScreen()), + ); + } + } + Future _markTOSRead() async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('IsReadeTOS', true); @@ -728,10 +748,14 @@ class _LoginScreenState extends State { Center( child: Column( children: [ - Image.asset( - 'assets/komet.png', - height: 80, - color: cs.onSurface, + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onLogoTap, + child: Image.asset( + 'assets/komet.png', + height: 80, + color: cs.onSurface, + ), ), const SizedBox(height: 16), Text( From d35729e911c33357c58131f1cd8570c23d8f19a4 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 2 Jun 2026 17:08:14 +0000 Subject: [PATCH 179/323] =?UTF-8?q?feat:=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20chatCacheFingerprint=20=D0=B8=20exp=20?= =?UTF-8?q?=D0=B2=20login-=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B5=20(opcode?= =?UTF-8?q?=2019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 ++- lib/backend/api.dart | 9 ++++ lib/backend/modules/account.dart | 16 +++++-- lib/core/protocol/chat_cache_fingerprint.dart | 47 +++++++++++++++++++ pubspec.lock | 2 +- pubspec.yaml | 1 + 6 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 lib/core/protocol/chat_cache_fingerprint.dart diff --git a/.gitignore b/.gitignore index f395ff9..d86649d 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,8 @@ agents.md # Environment variables .env -.env.* \ No newline at end of file +.env.* +# Локальные дампы трафика и скрипты анализа (содержат секреты) +komet.txt +original_app.txt +fingerprint.py diff --git a/lib/backend/api.dart b/lib/backend/api.dart index c767808..8b10cf9 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -39,6 +39,12 @@ class Api { Map? get userAgent => _userAgent; + int? _callsSeed; + String? _deviceId; + + int? get callsSeed => _callsSeed; + String? get deviceId => _deviceId; + List? _registrationCountries; List get registrationCountries => @@ -111,6 +117,7 @@ class Api { try { final response = await sendHandshake(); if (response.isOk) { + _callsSeed = response.payload['callsSeed'] as int?; _registrationCountries = _parseRegistrationCountries(response.payload); _setSessionState(SessionState.online); _startPinging(); @@ -242,6 +249,8 @@ class Api { 'buildNumber': buildNumber, }; + _deviceId = deviceId; + final payload = { 'mt_instanceid': await DeviceIdentity.instanceId(), 'clientSessionId': DeviceIdentity.clientSessionId, diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 70b4b9a..522901b 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import '../api.dart'; +import '../../core/protocol/chat_cache_fingerprint.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; @@ -1048,9 +1050,18 @@ class AccountModule { final payload = { 'token': token, 'interactive': true, - if (_api.userAgent != null) 'userAgent': _api.userAgent, + 'exp': { + 'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]), + }, }; + final callsSeed = _api.callsSeed; + final deviceId = _api.deviceId; + if (callsSeed != null && deviceId != null) { + payload['chatCacheFingerprint'] = + ChatCacheFingerprint.compute(callsSeed, deviceId); + } + if (sync != null) { payload['presenceSync'] = sync.presenceSync; payload['chatsSync'] = sync.chatsSync; @@ -1060,9 +1071,6 @@ class AccountModule { payload['bannersSync'] = sync.bannersSync; payload['lastLogin'] = sync.lastLogin; if (sync.configHash != null) payload['configHash'] = sync.configHash; - if (sync.chatCacheFingerprint != null) { - payload['chatCacheFingerprint'] = sync.chatCacheFingerprint; - } } else { payload['presenceSync'] = 0; } diff --git a/lib/core/protocol/chat_cache_fingerprint.dart b/lib/core/protocol/chat_cache_fingerprint.dart new file mode 100644 index 0000000..b2ab84a --- /dev/null +++ b/lib/core/protocol/chat_cache_fingerprint.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +class ChatCacheFingerprint { + static final Uint8List _signatureDigest = _hex( + '1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93', + ); + static final Uint8List _soDigest = _hex( + 'c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111', + ); + static final Uint8List _dexDigest = _hex( + '490a2746c7ebbff050353c575a186ca65bc708f9b6e0c1329b59a3bfab6c3924', + ); + + static Uint8List compute(int callsSeed, String deviceId) { + final seed = _int64BigEndian(callsSeed); + final device = Uint8List.fromList(utf8.encode(deviceId)); + final result = BytesBuilder(); + result.add(_sha256(_signatureDigest, seed, device)); + result.add(_sha256(_soDigest, seed, device)); + result.add(_sha256(_dexDigest, seed, device)); + return result.toBytes(); + } + + static List _sha256(Uint8List a, Uint8List b, Uint8List c) { + final builder = BytesBuilder() + ..add(a) + ..add(b) + ..add(c); + return sha256.convert(builder.toBytes()).bytes; + } + + static Uint8List _int64BigEndian(int value) { + final data = ByteData(8)..setInt64(0, value, Endian.big); + return data.buffer.asUint8List(); + } + + static Uint8List _hex(String hex) { + final out = Uint8List(hex.length ~/ 2); + for (var i = 0; i < out.length; i++) { + out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; + } +} diff --git a/pubspec.lock b/pubspec.lock index 0545e63..9dab85f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -130,7 +130,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/pubspec.yaml b/pubspec.yaml index 6fbaeec..edae9ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: dart_lz4: ^1.0.0 libcompress: ^1.0.0 msgpack_dart: ^1.0.1 + crypto: ^3.0.7 logger: ^2.6.2 device_info_plus: 12.3.0 flutter_timezone: ^5.0.1 From 5ff914b0dc3c6423317c7728d3d8aed1be10c870 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 2 Jun 2026 18:28:31 +0000 Subject: [PATCH 180/323] =?UTF-8?q?feat:=20=D0=BC=D0=B8=D0=BC=D0=B8=D0=BA?= =?UTF-8?q?=D1=80=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=B0=20(opcode=206=20+=20LZ4)=20=D0=B8=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D1=8B=20UI=20(=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B8,=202FA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 14 ++--- lib/backend/modules/account.dart | 5 ++ lib/core/config/app_stories.dart | 20 ++++++ lib/core/protocol/packet.dart | 26 ++++++-- lib/core/storage/spoofing_service.dart | 4 +- .../screens/chats/chat_list_screen.dart | 62 +++++++++++------- .../screens/profile/debug_menu_screen.dart | 63 +++++++++++++++++++ .../profile/password_entry_screen.dart | 10 ++- .../screens/profile/security_screen.dart | 10 ++- lib/main.dart | 3 + 10 files changed, 177 insertions(+), 40 deletions(-) create mode 100644 lib/core/config/app_stories.dart diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 8b10cf9..4221c4f 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -171,7 +171,7 @@ class Api { String architecture = 'arm64'; String appVersion = SpoofingService.hardcodedAppVersion; int buildNumber = SpoofingService.hardcodedBuildNumber; - String screen = '1920x1080'; + String screen = '420dpi 420dpi 1080x2340'; tz.initializeTimeZones(); final timeZoneName = await FlutterTimezone.getLocalTimezone(); @@ -237,25 +237,25 @@ class Api { _userAgent = { 'deviceType': deviceType, - 'locale': locale, - 'deviceLocale': deviceLocale, - 'osVersion': osVersion, - 'deviceName': deviceName, 'appVersion': appVersion, - 'screen': screen, + 'osVersion': osVersion, 'timezone': timezone, + 'screen': screen, 'pushDeviceType': 'GCM', 'arch': architecture, + 'locale': locale, 'buildNumber': buildNumber, + 'deviceName': deviceName, + 'deviceLocale': deviceLocale, }; _deviceId = deviceId; final payload = { 'mt_instanceid': await DeviceIdentity.instanceId(), + 'userAgent': _userAgent, 'clientSessionId': DeviceIdentity.clientSessionId, 'deviceId': deviceId, - 'userAgent': _userAgent, }; return sendRequest(Opcode.sessionInit, payload); diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 522901b..9c5a65a 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -674,6 +674,11 @@ class AccountModule { ); } + Future get2faStatus() async { + final trackId = await enter2faPanel(); + return get2faDetails(trackId); + } + Future check2faPassword(String trackId, String password) async { _ensureOnline(); final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, { diff --git a/lib/core/config/app_stories.dart b/lib/core/config/app_stories.dart new file mode 100644 index 0000000..4f75a2d --- /dev/null +++ b/lib/core/config/app_stories.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppStories { + static const prefKey = 'dev_stories'; + static const bool defaultValue = false; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? defaultValue; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 75af3a2..5b2b16e 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -82,7 +82,15 @@ String messageFromErrorPayload(dynamic payload) { return s.isNotEmpty ? s : 'Неизвестная ошибка'; } -/// Упаковка пакета для отправки на сервер +/// Payload меньше этого размера отправляется без сжатия (как в оригинале). +const int _compressionThreshold = 32; + +/// Упаковка пакета для отправки на сервер. +/// +/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold] +/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия: +/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по +/// которому получатель выделяет буфер под распаковку). Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { final header = ByteData(headerSize); header.setUint8(0, 10); @@ -90,11 +98,19 @@ Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { header.setUint16(2, seq, Endian.big); header.setUint16(4, opcode, Endian.big); - final payloadBytes = msgpack.serialize(payload); - final payloadLen = payloadBytes.length & 0xFFFFFF; - header.setUint32(6, payloadLen, Endian.big); + final raw = Uint8List.fromList(msgpack.serialize(payload)); - return Uint8List.fromList(header.buffer.asUint8List() + payloadBytes); + if (raw.length < _compressionThreshold) { + header.setUint32(6, raw.length & 0xFFFFFF, Endian.big); + return Uint8List.fromList(header.buffer.asUint8List() + raw); + } + + final compressed = lz4Compress(raw); + final compLen = compressed.length; + final flag = (raw.length ~/ compLen) + 1; + header.setUint32(6, ((flag & 0xFF) << 24) | (compLen & 0xFFFFFF), Endian.big); + + return Uint8List.fromList(header.buffer.asUint8List() + compressed); } /// Распаковка пакета от сервера diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index 548f5ad..b5e8c57 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -1,8 +1,8 @@ import 'package:shared_preferences/shared_preferences.dart'; class SpoofingService { - static const String hardcodedAppVersion = '26.14.1'; - static const int hardcodedBuildNumber = 6606; + static const String hardcodedAppVersion = '26.17.1'; + static const int hardcodedBuildNumber = 6712; static Future?> getSpoofedSessionData() async { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 6790abd..4b66e47 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -19,6 +19,7 @@ import '../auth/login_screen.dart'; import '../../widgets/account_switcher_overlay.dart'; import '../../../backend/api.dart'; import '../../../core/utils/haptics.dart'; +import '../../../core/config/app_stories.dart'; import '../../../backend/models/chat_folder.dart'; import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; @@ -406,6 +407,7 @@ class _ChatListScreenState extends State } bool _allowStoriesPullOverscrollTop() { + if (!AppStories.current.value) return false; if (_storiesDockedOpen || _storiesRevealController.isAnimating || _pullRatio > 0) { @@ -462,9 +464,22 @@ class _ChatListScreenState extends State } }); ChatsModule.chatsChanged.addListener(_onChatsChanged); + AppStories.current.addListener(_onStoriesEnabledChanged); _reloadChatsAndFolders(); } + void _onStoriesEnabledChanged() { + if (!mounted) return; + if (!AppStories.current.value) { + _storiesRevealController.stop(); + _pullRatio = 0; + _storiesDockedOpen = false; + _storiesAnimClosing = false; + _storiesOverscrollRevealArmed = false; + } + setState(() {}); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -977,6 +992,7 @@ class _ChatListScreenState extends State appRouteObserver.unsubscribe(this); _settleTimer?.cancel(); ChatsModule.chatsChanged.removeListener(_onChatsChanged); + AppStories.current.removeListener(_onStoriesEnabledChanged); _loginSub?.cancel(); _stateSub?.cancel(); _fabController.dispose(); @@ -1077,7 +1093,8 @@ class _ChatListScreenState extends State children: [ Row( children: [ - if (_pullRatio < 0.8) + if (AppStories.current.value && + _pullRatio < 0.8) Opacity( opacity: 1.0 - _pullRatio, child: Container( @@ -1156,30 +1173,31 @@ class _ChatListScreenState extends State ], ), ), - SizedBox( - height: 96 * _pullRatio, - child: Opacity( - opacity: _pullRatio, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, + if (AppStories.current.value) + SizedBox( + height: 96 * _pullRatio, + child: Opacity( + opacity: _pullRatio, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + children: [ + _buildStoryItem( + 'Даша', + 'https://i.pravatar.cc/150?u=dasha', + true, + ), + _buildStoryItem( + 'Мастика', + 'https://i.pravatar.cc/150?u=mastika', + false, + ), + ], ), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, - ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], ), ), - ), Padding( padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), child: Container( diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 0fcfd9e..6902b56 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_pranks.dart'; +import '../../../core/config/app_stories.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; @@ -453,6 +454,68 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppStories.current, + builder: (context, storiesOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.amp_stories, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Истории', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Отображение ленты историй в списке чатов', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: storiesOn, + onChanged: (v) { + AppStories.save(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index f127133..84ee9b9 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -24,10 +24,16 @@ class _PasswordEntryScreenState extends State { Future _check2faStatus() async { try { - final profile = await AppDatabase.loadActiveProfile(); + bool is2faEnabled; + try { + is2faEnabled = (await accountModule.get2faStatus()).enabled; + } catch (_) { + final profile = await AppDatabase.loadActiveProfile(); + is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + } if (mounted) { setState(() { - _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _is2faEnabled = is2faEnabled; _isLoading = false; }); } diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index a33306c..680234b 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -47,12 +47,18 @@ class _SecurityScreenState extends State accountModule.getBlockedContacts(), AppDatabase.loadActiveProfile(), ]); + bool is2faEnabled; + try { + is2faEnabled = (await accountModule.get2faStatus()).enabled; + } catch (_) { + final profile = results[2] as ProfileData?; + is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + } if (mounted) { setState(() { _privacyConfig = results[0] as PrivacyConfig; _blockedContacts = results[1] as List; - final profile = results[2] as ProfileData?; - _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _is2faEnabled = is2faEnabled; _isLoading = false; }); } diff --git a/lib/main.dart b/lib/main.dart index 9f611f9..4ddfaca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'core/config/app_fonts.dart'; import 'core/config/app_message_actions_style.dart'; import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_pranks.dart'; +import 'core/config/app_stories.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -84,6 +85,7 @@ void main() async { final messageActionsFuture = AppMessageActionsStyle.load(); final swipeBackFuture = AppSwipeBackDesktop.load(); final pranksFuture = AppPranks.load(); + final storiesFuture = AppStories.load(); await api.connect(); @@ -116,6 +118,7 @@ void main() async { AppMessageActionsStyle.current.value = await messageActionsFuture; AppSwipeBackDesktop.current.value = await swipeBackFuture; AppPranks.current.value = await pranksFuture; + AppStories.current.value = await storiesFuture; runApp( KometApp( initialLocale: initialLocale, From 1a3570f9427d300d65dbf0f3e7afe763357770a4 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 2 Jun 2026 20:06:46 +0000 Subject: [PATCH 181/323] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=BA=D0=B0=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0-?= =?UTF-8?q?=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20=E2=80=94=20?= =?UTF-8?q?=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D1=8B,=20=D0=B2=D0=B8=D0=B4?= =?UTF-8?q?=D0=B5=D0=BE,=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D1=84=D0=BE?= =?UTF-8?q?=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Опросы (новый тип сообщения): - модель Poll/PollAnswer и PollAttachment (_type: POLL) - PollsModule: загрузка через opcode 306 (GET_POLL_UPDATES) с кэшем - рендер опроса в баблах (вопрос, варианты, прогресс-бары, голоса) Видео: - getVideoUrl через opcode 83 (VIDEO_PLAY): выбор MP4_*/HLS из ответа - полноэкранный плеер (video_player) с play/pause и перемоткой - тап по видео в чате запускает воспроизведение Файлы: - getFileUrl через opcode 88 (FILE_DOWNLOAD) с корректным форматом {messageId, chatId, fileId} → url (раньше слался неверный {url, token}) - скачивание во временную папку и открытие системным приложением (path_provider, open_filex) Фото: - полноэкранный просмотрщик с зумом (был TODO-заглушкой) - фикс «сжатости»: декодирование с учётом devicePixelRatio вместо ×2 --- .gitignore | 1 + lib/backend/modules/messages.dart | 71 ++++++--- lib/backend/modules/polls.dart | 61 ++++++++ lib/core/utils/file_download.dart | 53 +++++++ lib/frontend/widgets/message_bubble.dart | 131 +++++++++++++++-- lib/frontend/widgets/photo_viewer.dart | 54 +++++++ lib/frontend/widgets/poll_view.dart | 139 ++++++++++++++++++ lib/frontend/widgets/video_player_screen.dart | 120 +++++++++++++++ lib/main.dart | 2 + lib/models/attachment.dart | 28 ++++ lib/models/poll.dart | 87 +++++++++++ pubspec.lock | 70 ++++++++- pubspec.yaml | 3 + 13 files changed, 787 insertions(+), 33 deletions(-) create mode 100644 lib/backend/modules/polls.dart create mode 100644 lib/core/utils/file_download.dart create mode 100644 lib/frontend/widgets/photo_viewer.dart create mode 100644 lib/frontend/widgets/poll_view.dart create mode 100644 lib/frontend/widgets/video_player_screen.dart create mode 100644 lib/models/poll.dart diff --git a/.gitignore b/.gitignore index d86649d..5509b1a 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,4 @@ agents.md komet.txt original_app.txt fingerprint.py +PCAPdroid_*.txt diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 7a29566..6108dcb 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -568,6 +568,43 @@ class MessagesModule { } } + /// Запрашивает у сервера ссылку на воспроизведение видео (opcode 83). + /// + /// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`, + /// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`. + /// Возвращает лучший доступный progressive-MP4 (или HLS как запасной). + Future getVideoUrl({ + required String messageId, + required int chatId, + required String token, + required int videoId, + }) async { + try { + final response = await _api.sendRequest(Opcode.videoPlay, { + 'messageId': int.tryParse(messageId) ?? 0, + 'chatId': chatId, + 'token': token, + 'videoId': videoId, + }); + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + const mp4Keys = ['MP4_1080', 'MP4_720', 'MP4_480', 'MP4_360', 'MP4_240']; + for (final key in mp4Keys) { + final url = data[key]; + if (url is String && url.isNotEmpty) return url; + } + final hls = data['HLS']; + if (hls is String && hls.isNotEmpty) return hls; + final external = data['EXTERNAL']; + if (external is String && external.isNotEmpty) return external; + return null; + } catch (_) { + return null; + } + } + Future downloadVideo(String baseUrl, String videoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { @@ -588,23 +625,6 @@ class MessagesModule { } } - Future getVideoUrl(String baseUrl, String videoToken) async { - try { - final response = await _api.sendRequest(Opcode.fileDownload, { - 'url': baseUrl, - 'token': videoToken, - }); - - if (!response.isOk) return null; - final data = response.payload; - if (data is! Map) return null; - - return data['content'] as String?; - } catch (e) { - return null; - } - } - Future downloadFile(String baseUrl, String fileToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { @@ -625,18 +645,27 @@ class MessagesModule { } } - Future getFileUrl(String baseUrl, String fileToken) async { + /// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88). + /// + /// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`, + /// ответ `{url: "https://fd.oneme.ru/getfile?..."}`. + Future getFileUrl({ + required String messageId, + required int chatId, + required int fileId, + }) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { - 'url': baseUrl, - 'token': fileToken, + 'messageId': int.tryParse(messageId) ?? 0, + 'chatId': chatId, + 'fileId': fileId, }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; - return data['content'] as String?; + return data['url'] as String?; } catch (e) { return null; } diff --git a/lib/backend/modules/polls.dart b/lib/backend/modules/polls.dart new file mode 100644 index 0000000..b3f9172 --- /dev/null +++ b/lib/backend/modules/polls.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; + +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../models/poll.dart'; + +class PollsModule extends ChangeNotifier { + final Api _api; + + PollsModule(this._api); + + final Map _cache = {}; + final Set _inFlight = {}; + + Poll? get(int pollId) => _cache[pollId]; + + Future fetch( + int chatId, + String messageId, + int pollId, { + bool force = false, + }) async { + if (pollId == 0) return; + if (!force && (_cache.containsKey(pollId) || _inFlight.contains(pollId))) { + return; + } + _inFlight.add(pollId); + try { + final mid = int.tryParse(messageId) ?? 0; + final response = await _api.sendRequest(Opcode.getPollUpdates, { + 'chatId': chatId, + 'polls': [ + {'messageId': mid, 'pollId': pollId}, + ], + }); + if (!response.isOk) return; + + final data = response.payload; + if (data is! Map) return; + + final polls = data['polls']; + if (polls is! List) return; + + var changed = false; + for (final p in polls) { + if (p is Map) { + final poll = Poll.fromServerMap(p); + if (poll.pollId != 0) { + _cache[poll.pollId] = poll; + changed = true; + } + } + } + if (changed) notifyListeners(); + } catch (_) { + // тихо игнорируем — опрос просто не отобразится + } finally { + _inFlight.remove(pollId); + } + } +} diff --git a/lib/core/utils/file_download.dart b/lib/core/utils/file_download.dart new file mode 100644 index 0000000..b40c0d5 --- /dev/null +++ b/lib/core/utils/file_download.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:open_filex/open_filex.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +class FileDownloadResult { + final bool ok; + final String? path; + final String? error; + + const FileDownloadResult({required this.ok, this.path, this.error}); +} + +/// Скачивает файл по [url] во временную папку под именем [fileName] +/// и открывает его системным приложением. +Future downloadAndOpenFile( + String url, + String fileName, +) async { + try { + final dir = await getTemporaryDirectory(); + final safeName = _sanitize(fileName); + final file = File(p.join(dir.path, safeName)); + + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + if (response.statusCode != 200) { + return FileDownloadResult(ok: false, error: 'HTTP ${response.statusCode}'); + } + final sink = file.openWrite(); + await response.pipe(sink); + } finally { + client.close(); + } + + final opened = await OpenFilex.open(file.path); + return FileDownloadResult( + ok: opened.type == ResultType.done, + path: file.path, + error: opened.type == ResultType.done ? null : opened.message, + ); + } catch (e) { + return FileDownloadResult(ok: false, error: e.toString()); + } +} + +String _sanitize(String name) { + final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); + return cleaned.isEmpty ? 'file' : cleaned; +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 3efeaae..cd76ebe 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -8,7 +8,12 @@ import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; import '../../core/utils/bubble_radius.dart'; import '../../core/utils/haptics.dart'; +import '../../core/utils/file_download.dart'; +import 'custom_notification.dart'; import '../../models/attachment.dart'; +import 'poll_view.dart'; +import 'photo_viewer.dart'; +import 'video_player_screen.dart'; enum MessageType { text, attachment, voice, control } @@ -745,6 +750,11 @@ class MessageBubble extends StatelessWidget { return _buildContactAttachment(ctx, contacts.first); } + final polls = attachments.whereType().toList(); + if (polls.isNotEmpty) { + return _buildPollAttachment(ctx, polls.first); + } + final photos = attachments.whereType().toList(); if (photos.isEmpty) { return _buildGenericAttachment(ctx, attachments.first); @@ -753,6 +763,21 @@ class MessageBubble extends StatelessWidget { return _buildPhotoContent(ctx, photos); } + Widget _buildPollAttachment(_BubbleCtx ctx, PollAttachment poll) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: PollView( + chatId: message.chatId, + messageId: message.id, + pollId: poll.pollId, + fallbackTitle: poll.title ?? message.text, + textColor: ctx.text, + dimColor: ctx.dim, + accentColor: ctx.text, + ), + ); + } + Widget _buildPhotoContent(_BubbleCtx ctx, List photos) { final hasCaption = message.text != null && message.text!.isNotEmpty; final count = photos.length; @@ -974,6 +999,7 @@ class MessageBubble extends StatelessWidget { final constrainedWidth = width.clamp(photoMinSize, photoMaxSize); final constrainedHeight = height.clamp(photoMinSize, photoMaxSize); + final dpr = MediaQuery.of(ctx.context).devicePixelRatio; final matchTop = ctx.hasPhotoWithCaption; final matchBottom = !ctx.hasPhotoWithCaption; @@ -999,8 +1025,8 @@ class MessageBubble extends StatelessWidget { width: constrainedWidth, height: constrainedHeight, fit: BoxFit.cover, - memCacheWidth: (constrainedWidth * 2).round(), - memCacheHeight: (constrainedHeight * 2).round(), + memCacheWidth: (constrainedWidth * dpr).round(), + memCacheHeight: (constrainedHeight * dpr).round(), fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => _buildPhotoPlaceholder( ctx.cs, @@ -1095,6 +1121,8 @@ class MessageBubble extends StatelessWidget { Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; + final cachePx = + (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round(); return AspectRatio( aspectRatio: 1, child: Stack( @@ -1105,8 +1133,8 @@ class MessageBubble extends StatelessWidget { fit: BoxFit.cover, width: double.infinity, height: double.infinity, - memCacheWidth: 280, - memCacheHeight: 280, + memCacheWidth: cachePx, + memCacheHeight: cachePx, fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, 100, 100), @@ -1130,6 +1158,8 @@ class MessageBubble extends StatelessWidget { String overlay, ) { final imageUrl = photo.baseUrl ?? ''; + final cachePx = + (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round(); return AspectRatio( aspectRatio: 1, child: Stack( @@ -1140,8 +1170,8 @@ class MessageBubble extends StatelessWidget { fit: BoxFit.cover, width: double.infinity, height: double.infinity, - memCacheWidth: 280, - memCacheHeight: 280, + memCacheWidth: cachePx, + memCacheHeight: cachePx, fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, 100, 100), @@ -1230,10 +1260,22 @@ class MessageBubble extends StatelessWidget { color: ctx.cs.onSurfaceVariant, ), ), + Center( + child: Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon(Symbols.play_arrow, + color: Colors.white, size: 30), + ), + ), Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () {}, + onTap: () => _playVideo(ctx.context, video), ), ), ], @@ -1245,6 +1287,36 @@ class MessageBubble extends StatelessWidget { ); } + Future _playVideo( + BuildContext context, + MessageAttachment video, + ) async { + final videoId = (video as dynamic).videoId as int?; + final token = (video as dynamic).videoToken as String?; + if (videoId == null || token == null) { + showCustomNotification(context, 'Не удалось открыть видео'); + return; + } + Haptics.tap(); + final url = await messagesModule.getVideoUrl( + messageId: message.id, + chatId: message.chatId, + token: token, + videoId: videoId, + ); + if (!context.mounted) return; + if (url == null) { + showCustomNotification(context, 'Не удалось получить видео'); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => VideoPlayerScreen(url: url), + ), + ); + } + Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) { final name = (file as dynamic).name as String? ?? 'File'; final size = (file as dynamic).size as int? ?? 0; @@ -1307,7 +1379,7 @@ class MessageBubble extends StatelessWidget { ), const SizedBox(width: 12), GestureDetector( - onTap: () {}, + onTap: () => _downloadFile(ctx.context, file, name), child: Container( width: 34, height: 34, @@ -1605,7 +1677,48 @@ class MessageBubble extends StatelessWidget { } void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) { - // TODO: Open photo viewer + final url = photo.baseUrl ?? ''; + if (url.isEmpty) return; + Navigator.of(ctx).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => PhotoViewerScreen(baseUrl: url), + ), + ); + } + + Future _downloadFile( + BuildContext context, + MessageAttachment file, + String name, + ) async { + final fileId = (file as dynamic).fileId as int?; + if (fileId == null) { + showCustomNotification(context, 'Не удалось определить файл'); + return; + } + Haptics.tap(); + showCustomNotification(context, 'Скачивание «$name»…'); + + final url = await messagesModule.getFileUrl( + messageId: message.id, + chatId: message.chatId, + fileId: fileId, + ); + if (!context.mounted) return; + if (url == null) { + showCustomNotification(context, 'Не удалось получить файл'); + return; + } + + final result = await downloadAndOpenFile(url, name); + if (!context.mounted) return; + if (!result.ok) { + showCustomNotification( + context, + 'Ошибка загрузки: ${result.error ?? 'не удалось открыть'}', + ); + } } Widget _buildVoiceContent(_BubbleCtx ctx) { diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart new file mode 100644 index 0000000..8cfc89e --- /dev/null +++ b/lib/frontend/widgets/photo_viewer.dart @@ -0,0 +1,54 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class PhotoViewerScreen extends StatelessWidget { + final String baseUrl; + + const PhotoViewerScreen({super.key, required this.baseUrl}); + + String get _url => baseUrl; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: InteractiveViewer( + minScale: 1, + maxScale: 5, + child: Center( + child: _url.isEmpty + ? const Icon(Symbols.broken_image, + color: Colors.white54, size: 64) + : CachedNetworkImage( + imageUrl: _url, + fit: BoxFit.contain, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + errorWidget: (_, _, _) => const Icon( + Symbols.broken_image, + color: Colors.white54, + size: 64, + ), + ), + ), + ), + ), + Positioned( + top: MediaQuery.of(context).padding.top + 8, + left: 8, + child: IconButton( + icon: const Icon(Symbols.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/widgets/poll_view.dart b/lib/frontend/widgets/poll_view.dart new file mode 100644 index 0000000..5a40436 --- /dev/null +++ b/lib/frontend/widgets/poll_view.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../../main.dart'; +import '../../models/poll.dart'; + +class PollView extends StatefulWidget { + final int chatId; + final String messageId; + final int pollId; + final String? fallbackTitle; + final Color textColor; + final Color dimColor; + final Color accentColor; + + const PollView({ + super.key, + required this.chatId, + required this.messageId, + required this.pollId, + required this.textColor, + required this.dimColor, + required this.accentColor, + this.fallbackTitle, + }); + + @override + State createState() => _PollViewState(); +} + +class _PollViewState extends State { + @override + void initState() { + super.initState(); + pollsModule.fetch(widget.chatId, widget.messageId, widget.pollId); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: pollsModule, + builder: (context, _) { + final poll = pollsModule.get(widget.pollId); + return _buildCard(poll); + }, + ); + } + + Widget _buildCard(Poll? poll) { + final title = poll?.title.isNotEmpty == true + ? poll!.title + : (widget.fallbackTitle ?? 'Опрос'); + + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 220, maxWidth: 280), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + color: widget.textColor, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + poll == null + ? 'Загрузка опроса…' + : _votesLabel(poll.total), + style: TextStyle(color: widget.dimColor, fontSize: 12), + ), + const SizedBox(height: 10), + if (poll != null) + ...poll.answers.map((a) => _buildAnswer(a, poll.total)), + ], + ), + ); + } + + Widget _buildAnswer(PollAnswer answer, int total) { + final pct = total > 0 ? answer.voteCount / total : 0.0; + final pctLabel = '${(pct * 100).round()}%'; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + answer.text, + style: TextStyle(color: widget.textColor, fontSize: 14), + ), + ), + const SizedBox(width: 8), + Text( + pctLabel, + style: TextStyle( + color: widget.dimColor, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: pct, + minHeight: 6, + backgroundColor: widget.dimColor.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(widget.accentColor), + ), + ), + ], + ), + ); + } + + String _votesLabel(int total) { + if (total == 0) return 'Нет голосов'; + final mod10 = total % 10; + final mod100 = total % 100; + String word; + if (mod10 == 1 && mod100 != 11) { + word = 'голос'; + } else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) { + word = 'голоса'; + } else { + word = 'голосов'; + } + return '$total $word'; + } +} diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart new file mode 100644 index 0000000..3382f80 --- /dev/null +++ b/lib/frontend/widgets/video_player_screen.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:video_player/video_player.dart'; + +class VideoPlayerScreen extends StatefulWidget { + final String url; + + const VideoPlayerScreen({super.key, required this.url}); + + @override + State createState() => _VideoPlayerScreenState(); +} + +class _VideoPlayerScreenState extends State { + VideoPlayerController? _controller; + bool _error = false; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + final controller = VideoPlayerController.networkUrl(Uri.parse(widget.url)); + _controller = controller; + try { + await controller.initialize(); + if (!mounted) return; + setState(() {}); + controller.play(); + controller.addListener(_onTick); + } catch (_) { + if (mounted) setState(() => _error = true); + } + } + + void _onTick() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } + + void _togglePlay() { + final c = _controller; + if (c == null || !c.value.isInitialized) return; + setState(() => c.value.isPlaying ? c.pause() : c.play()); + } + + @override + Widget build(BuildContext context) { + final c = _controller; + final ready = c != null && c.value.isInitialized; + + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Center( + child: _error + ? const Icon(Symbols.error, color: Colors.white54, size: 64) + : ready + ? AspectRatio( + aspectRatio: c.value.aspectRatio, + child: VideoPlayer(c), + ) + : const CircularProgressIndicator(color: Colors.white), + ), + if (ready) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _togglePlay, + child: AnimatedOpacity( + opacity: c.value.isPlaying ? 0 : 1, + duration: const Duration(milliseconds: 150), + child: Center( + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon(Symbols.play_arrow, + color: Colors.white, size: 40), + ), + ), + ), + ), + ), + if (ready) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: VideoProgressIndicator( + c, + allowScrubbing: true, + colors: const VideoProgressColors(playedColor: Colors.white), + ), + ), + Positioned( + top: MediaQuery.of(context).padding.top + 8, + left: 8, + child: IconButton( + icon: const Icon(Symbols.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 4ddfaca..2284455 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,6 +28,7 @@ import 'backend/modules/chats.dart'; import 'backend/modules/contacts.dart'; import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; +import 'backend/modules/polls.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; import 'core/transport/tls_config.dart'; @@ -44,6 +45,7 @@ import 'frontend/widgets/theme_reveal.dart'; final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); +final pollsModule = PollsModule(api); final fileUploader = FileUploader(api: api, messages: messagesModule); final RouteObserver> appRouteObserver = RouteObserver>(); diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index dd76314..4ee7718 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -7,6 +7,7 @@ enum AttachmentType { location, sticker, control, + poll, } abstract class MessageAttachment { @@ -41,6 +42,8 @@ abstract class MessageAttachment { return LocationAttachment.fromMap(map); case 'CONTROL': return ControlAttachment.fromMap(map); + case 'POLL': + return PollAttachment.fromMap(map); case 'SHARE': return FileAttachment.fromMap(map); case 'INLINE_KEYBOARD': @@ -471,6 +474,31 @@ class ControlAttachment extends MessageAttachment { }; } +class PollAttachment extends MessageAttachment { + final int pollId; + final String? title; + + const PollAttachment({ + required this.pollId, + this.title, + }) : super(type: AttachmentType.poll); + + factory PollAttachment.fromMap(Map map) { + final id = map['pollId'] ?? map['id']; + return PollAttachment( + pollId: id is int ? id : int.tryParse(id?.toString() ?? '') ?? 0, + title: (map['title'] ?? map['question'])?.toString(), + ); + } + + @override + Map toMap() => { + '_type': 'POLL', + 'pollId': pollId, + 'title': title, + }; +} + class ForwardedMessageAttachment extends MessageAttachment { final int originalSenderId; final String? originalSenderName; diff --git a/lib/models/poll.dart b/lib/models/poll.dart new file mode 100644 index 0000000..0f9cc3d --- /dev/null +++ b/lib/models/poll.dart @@ -0,0 +1,87 @@ +class PollAnswer { + final int answerId; + final String text; + final int voteCount; + final double rate; + final List votes; + + const PollAnswer({ + required this.answerId, + required this.text, + this.voteCount = 0, + this.rate = 0, + this.votes = const [], + }); +} + +class Poll { + final int pollId; + final String title; + final int settings; + final int version; + final int total; + final List answers; + final List voterPreviewIds; + + const Poll({ + required this.pollId, + required this.title, + this.settings = 0, + this.version = 0, + this.total = 0, + this.answers = const [], + this.voterPreviewIds = const [], + }); + + bool get isMultiple => settings & 0x1 != 0; + + bool votedBy(int userId) => + answers.any((a) => a.votes.contains(userId)); + + factory Poll.fromServerMap(Map map) { + final state = map['state']; + final stateMap = state is Map ? state : const {}; + + final resultsById = {}; + final result = stateMap['result']; + if (result is List) { + for (final r in result) { + if (r is Map && r['answerId'] is int) { + resultsById[r['answerId'] as int] = r; + } + } + } + + final answers = []; + final rawAnswers = map['answers']; + if (rawAnswers is List) { + for (final a in rawAnswers) { + if (a is! Map) continue; + final id = a['answerId'] as int? ?? 0; + final res = resultsById[id]; + answers.add(PollAnswer( + answerId: id, + text: a['text']?.toString() ?? '', + voteCount: (res?['voteCount'] as num?)?.toInt() ?? 0, + rate: (res?['rate'] as num?)?.toDouble() ?? 0, + votes: (res?['votes'] as List?) + ?.whereType() + .toList() ?? + const [], + )); + } + } + + return Poll( + pollId: map['pollId'] as int? ?? 0, + title: map['title']?.toString() ?? '', + settings: map['settings'] as int? ?? 0, + version: map['version'] as int? ?? 0, + total: (stateMap['total'] as num?)?.toInt() ?? 0, + answers: answers, + voterPreviewIds: + (stateMap['voterPreviewIds'] as List?)?.whereType().toList() ?? + const [], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9dab85f..79013ca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" dart_lz4: dependency: "direct main" description: @@ -397,6 +405,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -613,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" + url: "https://pub.dev" + source: hosted + version: "4.7.0" package_info_plus: dependency: "direct main" description: @@ -638,7 +662,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -970,6 +994,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "5d18d04084cc0cfc7afde39d0a308d4041e8ae6e9d5255bc086c263998dd1201" + url: "https://pub.dev" + source: hosted + version: "2.9.6" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58" + url: "https://pub.dev" + source: hosted + version: "2.9.7" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0" + url: "https://pub.dev" + source: hosted + version: "6.7.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: @@ -1027,5 +1091,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.4 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index edae9ba..71436b0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,9 @@ dependencies: package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 cached_network_image: ^3.4.1 + path_provider: ^2.1.4 + open_filex: ^4.5.0 + video_player: ^2.9.2 firebase_core: ^4.1.1 firebase_messaging: ^16.0.2 flutter_local_notifications: ^21.0.0 From 4bdb0e70cf08a960e248880b5a7ddf478e288a56 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 2 Jun 2026 20:40:03 +0000 Subject: [PATCH 182/323] =?UTF-8?q?feat:=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2/=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=20+=20=D1=83=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D1=8D?= =?UTF-8?q?=D1=88=D0=B5=D0=BC=20=D0=B2=20=D0=B4=D0=B5=D0=B2-=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_media_cache.dart | 33 +++ lib/core/utils/download_progress.dart | 15 ++ lib/core/utils/file_download.dart | 53 +++-- lib/core/utils/media_cache.dart | 158 +++++++++++++ .../screens/profile/debug_menu_screen.dart | 211 ++++++++++++++++++ lib/frontend/widgets/message_bubble.dart | 135 +++++++---- lib/frontend/widgets/video_player_screen.dart | 57 ++++- lib/main.dart | 3 + 8 files changed, 585 insertions(+), 80 deletions(-) create mode 100644 lib/core/config/app_media_cache.dart create mode 100644 lib/core/utils/download_progress.dart create mode 100644 lib/core/utils/media_cache.dart diff --git a/lib/core/config/app_media_cache.dart b/lib/core/config/app_media_cache.dart new file mode 100644 index 0000000..8ff5ee9 --- /dev/null +++ b/lib/core/config/app_media_cache.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppMediaCacheLimit { + static const prefKey = 'media_cache_limit_bytes'; + static const int defaultValue = 500 * 1024 * 1024; // 500 МБ + + /// Значение «без лимита» — вытеснение из кэша отключено. + static const int unlimited = 0; + + /// Доступные пресеты лимита, байты (0 — без лимита). + static const List presets = [ + 100 * 1024 * 1024, + 250 * 1024 * 1024, + 500 * 1024 * 1024, + 1024 * 1024 * 1024, + 2 * 1024 * 1024 * 1024, + unlimited, + ]; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(prefKey) ?? defaultValue; + } + + static Future save(int value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(prefKey, value); + } +} diff --git a/lib/core/utils/download_progress.dart b/lib/core/utils/download_progress.dart new file mode 100644 index 0000000..5af4219 --- /dev/null +++ b/lib/core/utils/download_progress.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; + +/// Прогресс активных загрузок вложений, ключ — имя в кэше. +/// +/// Значение: `null` — не загружается; `0..1` — доля загруженного. +class MediaDownloadProgress { + static final Map> _notifiers = {}; + + static ValueNotifier notifier(String key) => + _notifiers.putIfAbsent(key, () => ValueNotifier(null)); + + static void set(String key, double? value) { + notifier(key).value = value; + } +} diff --git a/lib/core/utils/file_download.dart b/lib/core/utils/file_download.dart index b40c0d5..a9fe696 100644 --- a/lib/core/utils/file_download.dart +++ b/lib/core/utils/file_download.dart @@ -1,8 +1,6 @@ -import 'dart:io'; - import 'package:open_filex/open_filex.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; + +import 'media_cache.dart'; class FileDownloadResult { final bool ok; @@ -12,28 +10,32 @@ class FileDownloadResult { const FileDownloadResult({required this.ok, this.path, this.error}); } -/// Скачивает файл по [url] во временную папку под именем [fileName] -/// и открывает его системным приложением. -Future downloadAndOpenFile( - String url, - String fileName, -) async { +/// Открывает файл из кэша, скачивая его при отсутствии. +/// +/// [cacheName] — стабильное имя в кэше (например, `_имя.ext`). +/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше, +/// чтобы не дёргать сервер за временной ссылкой повторно. +Future openCachedFile( + String cacheName, + Future Function() resolveUrl, { + void Function(double progress)? onProgress, +}) async { try { - final dir = await getTemporaryDirectory(); - final safeName = _sanitize(fileName); - final file = File(p.join(dir.path, safeName)); + var file = await MediaCache.existing(cacheName); - final client = HttpClient(); - try { - final request = await client.getUrl(Uri.parse(url)); - final response = await request.close(); - if (response.statusCode != 200) { - return FileDownloadResult(ok: false, error: 'HTTP ${response.statusCode}'); + if (file == null) { + final url = await resolveUrl(); + if (url == null || url.isEmpty) { + return const FileDownloadResult(ok: false, error: 'нет ссылки'); + } + file = await MediaCache.getOrDownload( + cacheName, + url, + onProgress: onProgress, + ); + if (file == null) { + return const FileDownloadResult(ok: false, error: 'ошибка загрузки'); } - final sink = file.openWrite(); - await response.pipe(sink); - } finally { - client.close(); } final opened = await OpenFilex.open(file.path); @@ -46,8 +48,3 @@ Future downloadAndOpenFile( return FileDownloadResult(ok: false, error: e.toString()); } } - -String _sanitize(String name) { - final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); - return cleaned.isEmpty ? 'file' : cleaned; -} diff --git a/lib/core/utils/media_cache.dart b/lib/core/utils/media_cache.dart new file mode 100644 index 0000000..b645561 --- /dev/null +++ b/lib/core/utils/media_cache.dart @@ -0,0 +1,158 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../config/app_media_cache.dart'; + +/// Постоянный дисковый кэш скачанных медиа (файлы, видео). +/// +/// Хранит файлы в `/media_cache/` под детерминированным именем +/// (обычно по id вложения), чтобы повторные открытия не качали заново. +class MediaCache { + /// Максимальный размер кэша (настраивается в дев-меню); при превышении + /// вытесняются старые файлы (LRU). + static int get maxBytes => AppMediaCacheLimit.current.value; + + static Directory? _dir; + + static Future _cacheDir() async { + final cached = _dir; + if (cached != null) return cached; + final base = await getApplicationSupportDirectory(); + final dir = Directory(p.join(base.path, 'media_cache')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + _dir = dir; + return dir; + } + + /// Путь к кэш-файлу с именем [name] (файл может ещё не существовать). + static Future fileFor(String name) async { + final dir = await _cacheDir(); + return File(p.join(dir.path, _sanitize(name))); + } + + /// Существует ли непустой кэш-файл [name]. + /// + /// При попадании обновляет mtime файла — это делает вытеснение LRU + /// (часто используемые файлы переживают очистку). + static Future existing(String name) async { + final file = await fileFor(name); + if (await file.exists() && await file.length() > 0) { + try { + await file.setLastModified(DateTime.now()); + } catch (_) {} + return file; + } + return null; + } + + /// Возвращает кэш-файл [name], скачивая [url] при отсутствии. + /// + /// Загрузка идёт во временный `.part` и переименовывается атомарно — + /// прерванная закачка не считается валидным кэшем. + static Future getOrDownload( + String name, + String url, { + void Function(double progress)? onProgress, + }) async { + final existingFile = await existing(name); + if (existingFile != null) return existingFile; + + final file = await fileFor(name); + final part = File('${file.path}.part'); + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + if (response.statusCode != 200) return null; + + final total = response.contentLength; + var received = 0; + final sink = part.openWrite(); + await for (final chunk in response) { + received += chunk.length; + sink.add(chunk); + if (onProgress != null && total > 0) { + onProgress(received / total); + } + } + await sink.close(); + await part.rename(file.path); + await _enforceLimit(); + return file; + } catch (_) { + if (await part.exists()) { + try { + await part.delete(); + } catch (_) {} + } + return null; + } finally { + client.close(); + } + } + + /// Суммарный размер кэша в байтах. + static Future currentSize() async { + final dir = await _cacheDir(); + var total = 0; + await for (final entity in dir.list()) { + if (entity is File) { + try { + total += await entity.length(); + } catch (_) {} + } + } + return total; + } + + /// Полностью очищает кэш. Возвращает число удалённых байт. + static Future clear() async { + final dir = await _cacheDir(); + var freed = 0; + await for (final entity in dir.list()) { + if (entity is File) { + try { + freed += await entity.length(); + await entity.delete(); + } catch (_) {} + } + } + return freed; + } + + /// Вытесняет старые файлы (по mtime), пока размер превышает [maxBytes]. + static Future _enforceLimit() async { + final dir = await _cacheDir(); + final files = []; + var total = 0; + await for (final entity in dir.list()) { + if (entity is File && !entity.path.endsWith('.part')) { + files.add(entity); + try { + total += await entity.length(); + } catch (_) {} + } + } + if (maxBytes <= 0 || total <= maxBytes) return; + + files.sort((a, b) => + a.statSync().modified.compareTo(b.statSync().modified)); + + for (final file in files) { + if (total <= maxBytes) break; + try { + total -= await file.length(); + await file.delete(); + } catch (_) {} + } + } + + static String _sanitize(String name) { + final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); + return cleaned.isEmpty ? 'file' : cleaned; + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 6902b56..3f6b5f0 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -6,9 +6,11 @@ import '../../../backend/modules/chats.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_stories.dart'; +import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; +import '../../../core/utils/media_cache.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; @@ -26,6 +28,92 @@ class _DebugMenuScreenState extends State { bool _hasSearched = false; final List<_SearchHit> _hits = []; final Map _errors = {}; + int _cacheSize = 0; + bool _clearingCache = false; + + @override + void initState() { + super.initState(); + _loadCacheSize(); + } + + Future _loadCacheSize() async { + final size = await MediaCache.currentSize(); + if (mounted) setState(() => _cacheSize = size); + } + + Future _clearCache() async { + if (_clearingCache) return; + setState(() => _clearingCache = true); + final freed = await MediaCache.clear(); + if (!mounted) return; + setState(() { + _clearingCache = false; + _cacheSize = 0; + }); + showCustomNotification(context, 'Кэш очищен (${_formatBytes(freed)})'); + } + + void _pickCacheLimit() { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Лимит кэша медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + for (final preset in AppMediaCacheLimit.presets) + ListTile( + title: Text( + _limitLabel(preset), + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + trailing: AppMediaCacheLimit.current.value == preset + ? Icon(Symbols.check, color: cs.primary) + : null, + onTap: () { + AppMediaCacheLimit.save(preset); + Navigator.pop(sheetContext); + setState(() {}); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + String _limitLabel(int bytes) => + bytes <= 0 ? 'Без лимита' : _formatBytes(bytes); + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes Б'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} ГБ'; + } @override void dispose() { @@ -516,6 +604,129 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: _pickCacheLimit, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.data_usage, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Лимит кэша медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _limitLabel(AppMediaCacheLimit.current.value), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: _clearingCache ? null : _clearCache, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.delete_sweep, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Очистить кэш медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _clearingCache + ? 'Очистка…' + : 'Занято: ${_formatBytes(_cacheSize)}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + if (_clearingCache) + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index cd76ebe..b5ffea5 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -9,6 +9,8 @@ import '../../core/config/app_bubble_shape.dart'; import '../../core/utils/bubble_radius.dart'; import '../../core/utils/haptics.dart'; import '../../core/utils/file_download.dart'; +import '../../core/utils/media_cache.dart'; +import '../../core/utils/download_progress.dart'; import 'custom_notification.dart'; import '../../models/attachment.dart'; import 'poll_view.dart'; @@ -1293,26 +1295,39 @@ class MessageBubble extends StatelessWidget { ) async { final videoId = (video as dynamic).videoId as int?; final token = (video as dynamic).videoToken as String?; - if (videoId == null || token == null) { + if (videoId == null) { showCustomNotification(context, 'Не удалось открыть видео'); return; } Haptics.tap(); - final url = await messagesModule.getVideoUrl( - messageId: message.id, - chatId: message.chatId, - token: token, - videoId: videoId, - ); + + final cacheName = 'video_$videoId.mp4'; + final cached = await MediaCache.existing(cacheName) != null; if (!context.mounted) return; - if (url == null) { - showCustomNotification(context, 'Не удалось получить видео'); - return; + + String? url; + if (!cached) { + if (token == null) { + showCustomNotification(context, 'Не удалось открыть видео'); + return; + } + url = await messagesModule.getVideoUrl( + messageId: message.id, + chatId: message.chatId, + token: token, + videoId: videoId, + ); + if (!context.mounted) return; + if (url == null) { + showCustomNotification(context, 'Не удалось получить видео'); + return; + } } + Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, - builder: (_) => VideoPlayerScreen(url: url), + builder: (_) => VideoPlayerScreen(cacheName: cacheName, url: url), ), ); } @@ -1321,6 +1336,8 @@ class MessageBubble extends StatelessWidget { final name = (file as dynamic).name as String? ?? 'File'; final size = (file as dynamic).size as int? ?? 0; final sizeStr = _formatFileSize(size); + final fileId = (file as dynamic).fileId as int?; + final cacheName = '${fileId}_$name'; return IntrinsicWidth( child: Padding( @@ -1366,35 +1383,61 @@ class MessageBubble extends StatelessWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - Text( - sizeStr, - style: TextStyle( - color: ctx.dim, - fontSize: 12, - height: 1.2, + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) => Text( + progress != null + ? '${(progress * 100).round()}% · $sizeStr' + : sizeStr, + style: TextStyle( + color: ctx.dim, + fontSize: 12, + height: 1.2, + ), ), ), ], ), ), const SizedBox(width: 12), - GestureDetector( - onTap: () => _downloadFile(ctx.context, file, name), - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - Symbols.download, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 18, - ), - ), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) { + final downloading = progress != null; + return GestureDetector( + onTap: downloading + ? null + : () => _downloadFile(ctx.context, file, name), + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: downloading + ? Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + ), + ) + : Icon( + Symbols.download, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 18, + ), + ), + ); + }, ), ], ), @@ -1698,20 +1741,20 @@ class MessageBubble extends StatelessWidget { return; } Haptics.tap(); - showCustomNotification(context, 'Скачивание «$name»…'); - final url = await messagesModule.getFileUrl( - messageId: message.id, - chatId: message.chatId, - fileId: fileId, + final cacheName = '${fileId}_$name'; + + MediaDownloadProgress.set(cacheName, 0); + final result = await openCachedFile( + cacheName, + () => messagesModule.getFileUrl( + messageId: message.id, + chatId: message.chatId, + fileId: fileId, + ), + onProgress: (p) => MediaDownloadProgress.set(cacheName, p), ); - if (!context.mounted) return; - if (url == null) { - showCustomNotification(context, 'Не удалось получить файл'); - return; - } - - final result = await downloadAndOpenFile(url, name); + MediaDownloadProgress.set(cacheName, null); if (!context.mounted) return; if (!result.ok) { showCustomNotification( diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart index 3382f80..66c48ac 100644 --- a/lib/frontend/widgets/video_player_screen.dart +++ b/lib/frontend/widgets/video_player_screen.dart @@ -1,11 +1,20 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:video_player/video_player.dart'; -class VideoPlayerScreen extends StatefulWidget { - final String url; +import '../../core/utils/media_cache.dart'; - const VideoPlayerScreen({super.key, required this.url}); +class VideoPlayerScreen extends StatefulWidget { + final String cacheName; + final String? url; + + const VideoPlayerScreen({ + super.key, + required this.cacheName, + this.url, + }); @override State createState() => _VideoPlayerScreenState(); @@ -14,6 +23,7 @@ class VideoPlayerScreen extends StatefulWidget { class _VideoPlayerScreenState extends State { VideoPlayerController? _controller; bool _error = false; + double _progress = 0; @override void initState() { @@ -22,7 +32,23 @@ class _VideoPlayerScreenState extends State { } Future _init() async { - final controller = VideoPlayerController.networkUrl(Uri.parse(widget.url)); + File? file = await MediaCache.existing(widget.cacheName); + if (file == null && widget.url != null) { + file = await MediaCache.getOrDownload( + widget.cacheName, + widget.url!, + onProgress: (p) { + if (mounted) setState(() => _progress = p); + }, + ); + } + if (!mounted) return; + if (file == null) { + setState(() => _error = true); + return; + } + + final controller = VideoPlayerController.file(file); _controller = controller; try { await controller.initialize(); @@ -69,7 +95,7 @@ class _VideoPlayerScreenState extends State { aspectRatio: c.value.aspectRatio, child: VideoPlayer(c), ) - : const CircularProgressIndicator(color: Colors.white), + : _buildLoading(), ), if (ready) Positioned.fill( @@ -83,7 +109,7 @@ class _VideoPlayerScreenState extends State { child: Container( width: 64, height: 64, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: Colors.black54, shape: BoxShape.circle, ), @@ -117,4 +143,23 @@ class _VideoPlayerScreenState extends State { ), ); } + + Widget _buildLoading() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Colors.white, + value: _progress > 0 && _progress < 1 ? _progress : null, + ), + if (_progress > 0 && _progress < 1) ...[ + const SizedBox(height: 12), + Text( + '${(_progress * 100).round()}%', + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ], + ); + } } diff --git a/lib/main.dart b/lib/main.dart index 2284455..89b0cdf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'core/config/app_message_actions_style.dart'; import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_pranks.dart'; import 'core/config/app_stories.dart'; +import 'core/config/app_media_cache.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -88,6 +89,7 @@ void main() async { final swipeBackFuture = AppSwipeBackDesktop.load(); final pranksFuture = AppPranks.load(); final storiesFuture = AppStories.load(); + final cacheLimitFuture = AppMediaCacheLimit.load(); await api.connect(); @@ -121,6 +123,7 @@ void main() async { AppSwipeBackDesktop.current.value = await swipeBackFuture; AppPranks.current.value = await pranksFuture; AppStories.current.value = await storiesFuture; + AppMediaCacheLimit.current.value = await cacheLimitFuture; runApp( KometApp( initialLocale: initialLocale, From db9de4d8a242385681d86ae45843f37527dfe1ec Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 3 Jun 2026 13:55:14 +0700 Subject: [PATCH 183/323] =?UTF-8?q?=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F:=20=D1=81=D0=BB=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=D0=BD=D1=8C=D1=86=D0=B0=20=D0=BD=D0=B0=D0=B1=D1=83=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D0=BB=D0=B4=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 12 +- lib/backend/modules/folders.dart | 9 +- lib/core/protocol/packet.dart | 36 +++-- lib/core/storage/app_database.dart | 21 +-- lib/core/transport/receiver.dart | 49 +++++-- lib/core/utils/media_cache.dart | 39 ++++- .../screens/chats/chat_list_screen.dart | 8 +- lib/frontend/screens/chats/chat_screen.dart | 137 +++++++++++------- lib/frontend/widgets/message_bubble.dart | 41 +++--- 9 files changed, 229 insertions(+), 123 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index a45a8e9..bcc1a7a 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -512,12 +512,15 @@ class ChatsModule { if (accountId == null) return; final dialogRows = await AppDatabase.loadDialogChats(accountId); - final byParticipant = >>{}; + final byParticipant = + row, CachedChat cached})>>{}; for (final row in dialogRows) { final cached = CachedChat.fromDbRow(row); for (final pid in cached.participants.keys) { if (pid == accountId) continue; - byParticipant.putIfAbsent(pid, () => []).add(row); + byParticipant + .putIfAbsent(pid, () => []) + .add((row: row, cached: cached)); } } @@ -529,8 +532,9 @@ class ChatsModule { final options = ContactCache.getOptions(contactId) ?? const {}; final affected = byParticipant[contactId]; if (affected == null) continue; - for (final row in affected) { - final cached = CachedChat.fromDbRow(row); + for (final entry in affected) { + final row = entry.row; + final cached = entry.cached; final sameTitle = cached.title == name; final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? ''); final sameOptions = cached.options.length == options.length && diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart index b2856d8..afa4860 100644 --- a/lib/backend/modules/folders.dart +++ b/lib/backend/modules/folders.dart @@ -44,10 +44,13 @@ class FoldersModule { List? foldersOrder, ) { if (foldersOrder == null || foldersOrder.isEmpty) return; - final orderedIds = foldersOrder.map((id) => id.toString()).toList(); + final orderIndex = {}; + for (var i = 0; i < foldersOrder.length; i++) { + orderIndex.putIfAbsent(foldersOrder[i].toString(), () => i); + } folders.sort((a, b) { - final aIndex = orderedIds.indexOf(a.id); - final bIndex = orderedIds.indexOf(b.id); + final aIndex = orderIndex[a.id] ?? -1; + final bIndex = orderIndex[b.id] ?? -1; if (aIndex == -1 && bIndex == -1) return 0; if (aIndex == -1) return 1; if (bIndex == -1) return -1; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 5b2b16e..448a866 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -92,25 +92,31 @@ const int _compressionThreshold = 32; /// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по /// которому получатель выделяет буфер под распаковку). Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { - final header = ByteData(headerSize); + final Uint8List raw = msgpack.serialize(payload); + + final List body; + final int flag; + if (raw.length < _compressionThreshold) { + body = raw; + flag = 0; + } else { + body = lz4Compress(raw); + flag = (raw.length ~/ body.length) + 1; + } + + final out = Uint8List(headerSize + body.length); + final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize); header.setUint8(0, 10); header.setUint8(1, CmdType.request); header.setUint16(2, seq, Endian.big); header.setUint16(4, opcode, Endian.big); - - final raw = Uint8List.fromList(msgpack.serialize(payload)); - - if (raw.length < _compressionThreshold) { - header.setUint32(6, raw.length & 0xFFFFFF, Endian.big); - return Uint8List.fromList(header.buffer.asUint8List() + raw); - } - - final compressed = lz4Compress(raw); - final compLen = compressed.length; - final flag = (raw.length ~/ compLen) + 1; - header.setUint32(6, ((flag & 0xFF) << 24) | (compLen & 0xFFFFFF), Endian.big); - - return Uint8List.fromList(header.buffer.asUint8List() + compressed); + header.setUint32( + 6, + ((flag & 0xFF) << 24) | (body.length & 0xFFFFFF), + Endian.big, + ); + out.setRange(headerSize, out.length, body); + return out; } /// Распаковка пакета от сервера diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 2f62001..f1cccfa 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -435,17 +435,20 @@ class AppDatabase { // Chats cache static Future saveChats(List> rows) async { + if (rows.isEmpty) return; try { final db = await _instance; - final batch = db.batch(); - for (final row in rows) { - batch.insert( - 'chats_cache', - row, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - await batch.commit(noResult: true); + await db.transaction((txn) async { + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + 'chats_cache', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); } catch (e) { logger.e("Ошибка при сохранении чата: $e"); } diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index e0a45ee..7321133 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -7,46 +7,73 @@ import '../utils/logger.dart'; /// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов. class PacketReceiver { Uint8List _buffer = Uint8List(0); + int _start = 0; + int _end = 0; static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта /// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы. /// Полностью синхронный — нарезка не блокируется на распаковке, поэтому /// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`. + /// + /// Накопление идёт без перекопирования всего буфера на каждый чанк: целые + /// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается + /// сдвигом указателя `_start`, а не пересборкой буфера. List feed(Uint8List data) { - final newBuffer = Uint8List(_buffer.length + data.length); - newBuffer.setAll(0, _buffer); - newBuffer.setAll(_buffer.length, data); - _buffer = newBuffer; + _append(data); - if (_buffer.length > _maxBufferSize) { + if (_end - _start > _maxBufferSize) { logger.e( - 'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс', + 'PacketReceiver: переполнение буфера (${_end - _start} B), сброс', ); reset(); return const []; } final packets = []; - while (_buffer.length >= headerSize) { + while (_end - _start >= headerSize) { final bd = ByteData.view( _buffer.buffer, - _buffer.offsetInBytes, + _buffer.offsetInBytes + _start, headerSize, ); final packedLen = bd.getUint32(6, Endian.big); final payloadLength = packedLen & 0xFFFFFF; final totalLength = headerSize + payloadLength; - if (_buffer.length < totalLength) break; + if (_end - _start < totalLength) break; - packets.add(Uint8List.sublistView(_buffer, 0, totalLength)); - _buffer = _buffer.sublist(totalLength); + packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength)); + _start += totalLength; + } + + if (_start == _end) { + _start = 0; + _end = 0; } return packets; } + void _append(Uint8List data) { + final pending = _end - _start; + if (pending == 0) { + _buffer = Uint8List.fromList(data); + _start = 0; + _end = data.length; + return; + } + final total = pending + data.length; + final newBuffer = Uint8List(total); + newBuffer.setRange(0, pending, _buffer, _start); + newBuffer.setRange(pending, total, data); + _buffer = newBuffer; + _start = 0; + _end = total; + } + void reset() { _buffer = Uint8List(0); + _start = 0; + _end = 0; } } diff --git a/lib/core/utils/media_cache.dart b/lib/core/utils/media_cache.dart index b645561..cc274ac 100644 --- a/lib/core/utils/media_cache.dart +++ b/lib/core/utils/media_cache.dart @@ -15,6 +15,7 @@ class MediaCache { static int get maxBytes => AppMediaCacheLimit.current.value; static Directory? _dir; + static int? _cachedSize; static Future _cacheDir() async { final cached = _dir; @@ -81,6 +82,12 @@ class MediaCache { } await sink.close(); await part.rename(file.path); + final known = _cachedSize; + if (known != null) { + try { + _cachedSize = known + await file.length(); + } catch (_) {} + } await _enforceLimit(); return file; } catch (_) { @@ -96,7 +103,18 @@ class MediaCache { } /// Суммарный размер кэша в байтах. + /// + /// Результат держится в памяти и поддерживается инкрементально при + /// загрузке/очистке/вытеснении — повторные вызовы не пересканируют каталог. static Future currentSize() async { + final cached = _cachedSize; + if (cached != null) return cached; + final total = await _scanSize(); + _cachedSize = total; + return total; + } + + static Future _scanSize() async { final dir = await _cacheDir(); var total = 0; await for (final entity in dir.list()) { @@ -121,34 +139,43 @@ class MediaCache { } catch (_) {} } } + _cachedSize = 0; return freed; } /// Вытесняет старые файлы (по mtime), пока размер превышает [maxBytes]. + /// + /// Под лимитом — ранний выход без сканирования каталога (частый случай). + /// Каталог обходится только когда лимит реально превышен. static Future _enforceLimit() async { + final limit = maxBytes; + if (limit <= 0) return; + + var total = _cachedSize ?? await _scanSize(); + if (total <= limit) { + _cachedSize = total; + return; + } + final dir = await _cacheDir(); final files = []; - var total = 0; await for (final entity in dir.list()) { if (entity is File && !entity.path.endsWith('.part')) { files.add(entity); - try { - total += await entity.length(); - } catch (_) {} } } - if (maxBytes <= 0 || total <= maxBytes) return; files.sort((a, b) => a.statSync().modified.compareTo(b.statSync().modified)); for (final file in files) { - if (total <= maxBytes) break; + if (total <= limit) break; try { total -= await file.length(); await file.delete(); } catch (_) {} } + _cachedSize = total; } static String _sanitize(String name) { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 4b66e47..25ba06a 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -183,8 +183,12 @@ class _ChatListScreenState extends State List _selectedChatObjects() { if (_selectedChats.isEmpty) return const []; - final ids = _selectedChats; - return _chats.where((c) => ids.contains(c.id.toString())).toList(); + final ids = {}; + for (final s in _selectedChats) { + final v = int.tryParse(s); + if (v != null) ids.add(v); + } + return _chats.where((c) => ids.contains(c.id)).toList(); } _DeleteKind _categorizeChat(CachedChat c, int myId) { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 41e0cbe..35981c2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -136,8 +136,10 @@ class _ChatScreenState extends State Timer? _shimmerStartTimer; bool _historyKickedOff = false; List _messages = []; + int _messagesRevision = 0; List? _combinedItemsCache; int? _combinedItemsKey; + bool _floatingDateScheduled = false; int _myId = 0; CachedChat? chat; @@ -146,7 +148,6 @@ class _ChatScreenState extends State late final AnimationController _floatingDateAnimController; late final CurvedAnimation _floatingDateCurved; final Map _separatorKeys = {}; - double _lastScrollOffset = 0; String? _lastSentId; @override @@ -226,6 +227,7 @@ class _ChatScreenState extends State .toList(); setState(() { _messages = first; + _messagesRevision++; _isLoading = false; _onLoadingFinished(); }); @@ -349,7 +351,10 @@ class _ChatScreenState extends State final changed = !_listsEquivalent(_messages, merged); if (!changed && !markLoaded) return; setState(() { - if (changed) _messages = merged; + if (changed) { + _messages = merged; + _messagesRevision++; + } if (markLoaded) { _isLoading = false; _onLoadingFinished(); @@ -590,6 +595,7 @@ class _ChatScreenState extends State setState(() { _lastSentId = message.id; _messages.add(message); + _messagesRevision++; }); _clearTyping(message.senderId); Haptics.tap(); @@ -598,11 +604,17 @@ class _ChatScreenState extends State case MessageEditedEvent(:final message): final idx = _messages.indexWhere((m) => m.id == message.id); if (idx == -1) return; - setState(() => _messages[idx] = message); + setState(() { + _messages[idx] = message; + _messagesRevision++; + }); case MessageRemovedEvent(:final messageId): final idx = _messages.indexWhere((m) => m.id == messageId); if (idx == -1) return; - setState(() => _messages.removeAt(idx)); + setState(() { + _messages.removeAt(idx); + _messagesRevision++; + }); _reactionNotifiers.remove(messageId)?.dispose(); case MessageReactionsChangedEvent(:final messageId, :final reactionInfo): _reactionNotifiers[messageId]?.value = reactionInfo; @@ -722,6 +734,7 @@ class _ChatScreenState extends State setState(() { _lastSentId = tempId; _messages.add(tempMessage); + _messagesRevision++; _messageController.clear(); }); unawaited(_persistOutgoing(tempMessage)); @@ -748,6 +761,7 @@ class _ChatScreenState extends State ); setState(() { _messages[index] = sent; + _messagesRevision++; }); unawaited(_persistOutgoing(sent, removeId: tempId)); } @@ -776,6 +790,7 @@ class _ChatScreenState extends State ); setState(() { _messages[index] = failed; + _messagesRevision++; }); unawaited(_persistOutgoing(failed)); } @@ -806,48 +821,61 @@ class _ChatScreenState extends State } if (forwardIds.isEmpty) return; + final resolved = {}; for (final id in forwardIds) { final name = await messagesModule.searchContactById(id); - final avatar = ContactCache.getAvatar(id); - if (name != null && mounted) { - setState(() { - for (var i = 0; i < _messages.length; i++) { - final msg = _messages[i]; - if (msg.attachments != null) { - final newAttaches = msg.attachments!.map((a) { - if (a is ForwardedMessageAttachment && - a.originalSenderId == id && - a.originalSenderName == null) { - return ForwardedMessageAttachment( - originalSenderId: id, - originalSenderName: name, - originalSenderAvatar: avatar, - originalMessageId: a.originalMessageId, - originalTime: a.originalTime, - originalText: a.originalText, - originalChatId: a.originalChatId, - originalAttachments: a.originalAttachments, - originalContact: a.originalContact, - ); - } - return a; - }).toList(); - _messages[i] = CachedMessage( - id: msg.id, - accountId: msg.accountId, - chatId: msg.chatId, - senderId: msg.senderId, - text: msg.text, - time: msg.time, - status: msg.status, - payload: msg.payload, - attachments: newAttaches, - ); - } - } - }); + if (name != null) { + resolved[id] = (name: name, avatar: ContactCache.getAvatar(id)); } } + if (resolved.isEmpty || !mounted) return; + + var anyChanged = false; + for (var i = 0; i < _messages.length; i++) { + final msg = _messages[i]; + final attaches = msg.attachments; + if (attaches == null) continue; + + var msgChanged = false; + final newAttaches = attaches.map((a) { + if (a is ForwardedMessageAttachment && + a.originalSenderName == null && + resolved.containsKey(a.originalSenderId)) { + final r = resolved[a.originalSenderId]!; + msgChanged = true; + return ForwardedMessageAttachment( + originalSenderId: a.originalSenderId, + originalSenderName: r.name, + originalSenderAvatar: r.avatar, + originalMessageId: a.originalMessageId, + originalTime: a.originalTime, + originalText: a.originalText, + originalChatId: a.originalChatId, + originalAttachments: a.originalAttachments, + originalContact: a.originalContact, + ); + } + return a; + }).toList(); + + if (!msgChanged) continue; + anyChanged = true; + _messages[i] = CachedMessage( + id: msg.id, + accountId: msg.accountId, + chatId: msg.chatId, + senderId: msg.senderId, + text: msg.text, + time: msg.time, + status: msg.status, + payload: msg.payload, + attachments: newAttaches, + ); + } + + if (anyChanged) { + setState(() => _messagesRevision++); + } } void _scrollToBottom() { @@ -863,7 +891,7 @@ class _ChatScreenState extends State } List _buildCombinedItems() { - final key = Object.hashAll(_messages.map(identityHashCode)); + final key = Object.hash(_messagesRevision, _messages.length); final cached = _combinedItemsCache; if (cached != null && _combinedItemsKey == key) return cached; @@ -906,25 +934,22 @@ class _ChatScreenState extends State void _onScrollForDate() { if (!_scrollController.hasClients) return; - final currentOffset = _scrollController.position.pixels; - final scrollingUp = currentOffset > _lastScrollOffset; - _lastScrollOffset = currentOffset; _floatingDateTimer?.cancel(); - - if (!scrollingUp) { - _floatingDateAnimController.reverse(); - return; - } - - _floatingDateTimer = Timer(const Duration(seconds: 2), () { + _floatingDateTimer = Timer(const Duration(seconds: 1), () { if (mounted) _floatingDateAnimController.reverse(); }); - WidgetsBinding.instance.addPostFrameCallback((_) => _updateFloatingDate()); + + if (_floatingDateScheduled) return; + _floatingDateScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _floatingDateScheduled = false; + _updateFloatingDate(); + }); } void _updateFloatingDate() { - if (!mounted) return; + if (!mounted || _separatorKeys.isEmpty) return; DateTime? result; final listRenderBox = _listKey.currentContext?.findRenderObject(); @@ -1611,6 +1636,7 @@ class _ChatScreenState extends State setState(() { _lastSentId = tempId; _messages.add(msg); + _messagesRevision++; }); Haptics.send(); _scrollToBottom(); @@ -1638,6 +1664,7 @@ class _ChatScreenState extends State payload: old.payload, attachments: attachment != null ? [attachment] : old.attachments, ); + _messagesRevision++; }); } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index b5ffea5..a8f5ef9 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1924,7 +1924,7 @@ class _VoiceMessageBubble extends StatefulWidget { class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { bool _isPlaying = false; - double _progress = 0.0; + final ValueNotifier _progress = ValueNotifier(0.0); bool _transcriptionVisible = false; String? _transcriptionText; bool _transcriptionLoading = false; @@ -1935,6 +1935,12 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { _transcriptionText = widget.preloadedText; } + @override + void dispose() { + _progress.dispose(); + super.dispose(); + } + String _formatDuration(int seconds) { final min = seconds ~/ 60; final sec = seconds % 60; @@ -2027,18 +2033,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { builder: (context, constraints) { return GestureDetector( onTapDown: (details) { - setState(() { - _progress = (details.localPosition.dx / - constraints.maxWidth) - .clamp(0.0, 1.0); - }); + _progress.value = + (details.localPosition.dx / constraints.maxWidth) + .clamp(0.0, 1.0); }, onHorizontalDragUpdate: (details) { - setState(() { - _progress = (details.localPosition.dx / - constraints.maxWidth) - .clamp(0.0, 1.0); - }); + _progress.value = + (details.localPosition.dx / constraints.maxWidth) + .clamp(0.0, 1.0); }, child: Container( height: 4, @@ -2046,13 +2048,16 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { color: waveInactiveColor, borderRadius: BorderRadius.circular(2), ), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: _progress.clamp(0.0, 1.0), - child: Container( - decoration: BoxDecoration( - color: waveActiveColor, - borderRadius: BorderRadius.circular(2), + child: ValueListenableBuilder( + valueListenable: _progress, + builder: (context, progress, _) => FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: waveActiveColor, + borderRadius: BorderRadius.circular(2), + ), ), ), ), From 7f29b3285e105b94cfbb4833169a3e85c5a23473 Mon Sep 17 00:00:00 2001 From: klockky Date: Wed, 3 Jun 2026 08:20:03 +0000 Subject: [PATCH 184/323] feat(auth): implement phone registration flow with preset avatars --- lib/backend/modules/account.dart | 100 ++++++ .../auth/code_confirmation_screen.dart | 15 + .../screens/auth/registration_screen.dart | 327 ++++++++++++++++++ lib/l10n/app_en.arb | 5 +- lib/l10n/app_localizations.dart | 18 + lib/l10n/app_localizations_en.dart | 9 + lib/l10n/app_localizations_ru.dart | 9 + lib/l10n/app_ru.arb | 5 +- 8 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 lib/frontend/screens/auth/registration_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 9c5a65a..88cde47 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -225,6 +225,20 @@ class RequestCodeResult { const RequestCodeResult({required this.token}); } +class PresetAvatar { + final int id; + final String url; + + const PresetAvatar({required this.id, required this.url}); +} + +class PresetAvatarCategory { + final String name; + final List avatars; + + const PresetAvatarCategory({required this.name, required this.avatars}); +} + class VerifyCodeResult { final Map payload; @@ -234,6 +248,37 @@ class VerifyCodeResult { String? get registerToken => _nestedToken('REGISTER'); + bool get isRegistration => registerToken != null && loginToken == null; + + List get presetAvatars { + final raw = payload['presetAvatars']; + if (raw is! List) return const []; + final categories = []; + for (final cat in raw) { + if (cat is! Map) continue; + final avatarsRaw = cat['avatars']; + if (avatarsRaw is! List) continue; + final avatars = []; + for (final a in avatarsRaw) { + if (a is! Map) continue; + final id = a['id']; + final url = a['url']; + if (id is int && url is String && url.isNotEmpty) { + avatars.add(PresetAvatar(id: id, url: url)); + } + } + if (avatars.isNotEmpty) { + categories.add( + PresetAvatarCategory( + name: cat['name']?.toString() ?? '', + avatars: avatars, + ), + ); + } + } + return categories; + } + bool get requiresPassword => payload['passwordChallenge'] != null; Map? get passwordChallenge { @@ -850,6 +895,61 @@ class AccountModule { return result; } + Future completeRegistration({ + required String token, + required String firstName, + String? lastName, + int? photoId, + }) async { + _ensureOnline(); + + final payload = { + 'token': token, + 'tokenType': AuthRequestType.register.value, + 'firstName': firstName, + }; + if (lastName != null && lastName.isNotEmpty) { + payload['lastName'] = lastName; + } + if (photoId != null) { + payload['photoId'] = photoId; + payload['avatarType'] = 'PRESET_AVATAR'; + } + + logger.i('Завершение регистрации (opcode=${Opcode.authConfirm})'); + + final packet = await _api.sendRequest(Opcode.authConfirm, payload); + + _checkPacketError(packet, 'completeRegistration'); + + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'completeRegistration: неожиданный тип payload: ${data.runtimeType}', + ); + } + + final profileMap = data['profile']; + if (profileMap is! Map) { + throw Exception('completeRegistration: отсутствует profile в ответе'); + } + final contact = profileMap['contact']; + if (contact is! Map) { + throw Exception('completeRegistration: отсутствует profile.contact'); + } + final accountId = contact['id'] as int?; + if (accountId == null) { + throw Exception('completeRegistration: отсутствует id аккаунта'); + } + + final profile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(profile, isActive: true); + await TokenStorage.setActiveAccount(accountId); + + logger.i('Регистрация завершена, accountId=$accountId'); + return accountId; + } + Future login({ int? accountId, String? token, diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 5a52c01..0155fe4 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -4,6 +4,7 @@ import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'password_2fa_screen.dart'; +import 'registration_screen.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; @@ -167,6 +168,20 @@ class _CodeConfirmationScreenState extends State return; } + if (result.isRegistration) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => RegistrationScreen( + phoneNumber: widget.phoneNumber, + registerToken: result.registerToken!, + presetAvatars: result.presetAvatars, + ), + ), + ); + return; + } + final loginResult = await accountModule.login(); if (!mounted) return; diff --git a/lib/frontend/screens/auth/registration_screen.dart b/lib/frontend/screens/auth/registration_screen.dart new file mode 100644 index 0000000..6347c3b --- /dev/null +++ b/lib/frontend/screens/auth/registration_screen.dart @@ -0,0 +1,327 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +import '../../../backend/modules/account.dart'; +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/login_success_screen.dart'; + +class RegistrationScreen extends StatefulWidget { + final String phoneNumber; + final String registerToken; + final List presetAvatars; + + const RegistrationScreen({ + super.key, + required this.phoneNumber, + required this.registerToken, + required this.presetAvatars, + }); + + @override + State createState() => _RegistrationScreenState(); +} + +class _RegistrationScreenState extends State { + final TextEditingController _firstNameController = TextEditingController(); + final TextEditingController _lastNameController = TextEditingController(); + + int? _selectedPhotoId; + String? _selectedAvatarUrl; + bool _isSubmitting = false; + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + bool get _canSubmit => + !_isSubmitting && _firstNameController.text.trim().isNotEmpty; + + Future _submit() async { + final firstName = _firstNameController.text.trim(); + if (firstName.isEmpty) return; + final lastName = _lastNameController.text.trim(); + + setState(() => _isSubmitting = true); + try { + final accountId = await accountModule.completeRegistration( + token: widget.registerToken, + firstName: firstName, + lastName: lastName.isEmpty ? null : lastName, + photoId: _selectedPhotoId, + ); + + final loginResult = await accountModule.login( + accountId: accountId, + token: '', + ); + + if (!mounted) return; + + final avatar = await precacheLoginAvatar( + context, + loginResult.profile.baseUrl, + ); + + if (!mounted) return; + + Navigator.pushAndRemoveUntil( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), + transitionsBuilder: (_, animation, __, child) => + FadeTransition(opacity: animation, child: child), + ), + (route) => false, + ); + } catch (e) { + if (!mounted) return; + setState(() => _isSubmitting = false); + showCustomNotification(context, e.toString()); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final firstName = _firstNameController.text.trim(); + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant), + onPressed: _isSubmitting ? null : () => Navigator.pop(context), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _canSubmit ? _submit : null, + backgroundColor: _canSubmit + ? cs.primaryContainer + : cs.surfaceContainerHighest, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + child: _isSubmitting + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: cs.onSurfaceVariant, + ), + ) + : Icon( + Icons.arrow_forward, + color: _canSubmit + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 96), + children: [ + Text( + l10n.registrationTitle, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 26, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.registrationSubtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 1.4, + ), + ), + const SizedBox(height: 28), + Center( + child: Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: ClipOval( + child: _selectedAvatarUrl != null + ? CachedNetworkImage( + imageUrl: _selectedAvatarUrl!, + fit: BoxFit.cover, + ) + : Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + firstName.isNotEmpty + ? firstName[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 28), + _buildTextField( + cs, + label: l10n.editProfileFirstName, + controller: _firstNameController, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 14), + _buildTextField( + cs, + label: l10n.editProfileLastName, + controller: _lastNameController, + textInputAction: TextInputAction.done, + ), + if (widget.presetAvatars.isNotEmpty) ...[ + const SizedBox(height: 28), + Text( + l10n.registrationChooseAvatar, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + for (final category in widget.presetAvatars) + _buildAvatarCategory(cs, category), + ], + ], + ), + ), + ); + } + + Widget _buildTextField( + ColorScheme cs, { + required String label, + required TextEditingController controller, + required TextInputAction textInputAction, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + TextField( + controller: controller, + enabled: !_isSubmitting, + textInputAction: textInputAction, + onChanged: (_) => setState(() {}), + style: TextStyle(color: cs.onSurface, fontSize: 15), + decoration: InputDecoration( + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } + + Widget _buildAvatarCategory(ColorScheme cs, PresetAvatarCategory category) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + if (category.name.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 10), + child: Text( + category.name, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + height: 64, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: category.avatars.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final avatar = category.avatars[index]; + final selected = _selectedPhotoId == avatar.id; + return GestureDetector( + onTap: _isSubmitting + ? null + : () => setState(() { + _selectedPhotoId = avatar.id; + _selectedAvatarUrl = avatar.url; + }), + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected ? cs.primary : Colors.transparent, + width: 2.5, + ), + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: ClipOval( + child: CachedNetworkImage( + imageUrl: avatar.url, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: cs.surfaceContainerHigh, + ), + errorWidget: (_, __, ___) => Container( + color: cs.surfaceContainerHigh, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 963fb5c..8c9a99f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -168,5 +168,8 @@ "editProfileSave": "Save", "editProfileFirstName": "First name", "editProfileLastName": "Last name", - "editProfileRemovePhoto": "Remove photo" + "editProfileRemovePhoto": "Remove photo", + "registrationTitle": "Create your profile", + "registrationSubtitle": "Add your name and pick an avatar", + "registrationChooseAvatar": "Choose an avatar" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ec39c9d..eef4bee 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1009,6 +1009,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Remove photo'** String get editProfileRemovePhoto; + + /// No description provided for @registrationTitle. + /// + /// In en, this message translates to: + /// **'Create your profile'** + String get registrationTitle; + + /// No description provided for @registrationSubtitle. + /// + /// In en, this message translates to: + /// **'Add your name and pick an avatar'** + String get registrationSubtitle; + + /// No description provided for @registrationChooseAvatar. + /// + /// In en, this message translates to: + /// **'Choose an avatar'** + String get registrationChooseAvatar; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 62d18b3..8303e6b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -478,4 +478,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get editProfileRemovePhoto => 'Remove photo'; + + @override + String get registrationTitle => 'Create your profile'; + + @override + String get registrationSubtitle => 'Add your name and pick an avatar'; + + @override + String get registrationChooseAvatar => 'Choose an avatar'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7b439e7..20ab999 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -480,4 +480,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get editProfileRemovePhoto => 'Удалить фото'; + + @override + String get registrationTitle => 'Создание профиля'; + + @override + String get registrationSubtitle => 'Укажите имя и выберите аватар'; + + @override + String get registrationChooseAvatar => 'Выберите аватар'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7b3739e..ef69871 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -168,5 +168,8 @@ "editProfileSave": "Сохранить", "editProfileFirstName": "Имя", "editProfileLastName": "Фамилия", - "editProfileRemovePhoto": "Удалить фото" + "editProfileRemovePhoto": "Удалить фото", + "registrationTitle": "Создание профиля", + "registrationSubtitle": "Укажите имя и выберите аватар", + "registrationChooseAvatar": "Выберите аватар" } From 515f3cacf37f3fec644989d0b29106dfa19a1492 Mon Sep 17 00:00:00 2001 From: nox Date: Wed, 3 Jun 2026 11:31:14 +0000 Subject: [PATCH 185/323] =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=20=D0=BC=D0=B5=D0=BB=D0=BA=D0=B8=D0=B5=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20=D1=87=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/calls/call_screen.dart | 381 ++++++++++++++++++ .../screens/chats/chat_list_screen.dart | 11 +- .../screens/profile/debug_menu_screen.dart | 129 ++++++ 3 files changed, 515 insertions(+), 6 deletions(-) create mode 100644 lib/frontend/screens/calls/call_screen.dart diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart new file mode 100644 index 0000000..4ca0beb --- /dev/null +++ b/lib/frontend/screens/calls/call_screen.dart @@ -0,0 +1,381 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +enum CallScreenState { incoming, outgoing, active } + +class CallScreen extends StatefulWidget { + final String name; + final String? avatarUrl; + final CallScreenState initialState; + + const CallScreen({ + super.key, + required this.name, + this.avatarUrl, + this.initialState = CallScreenState.incoming, + }); + + @override + State createState() => _CallScreenState(); +} + +class _CallScreenState extends State + with SingleTickerProviderStateMixin { + late CallScreenState _state; + Timer? _timer; + int _seconds = 0; + bool _isMuted = false; + bool _isSpeaker = false; + late AnimationController _pulseController; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + _state = widget.initialState; + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + _pulseAnimation = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + if (_state == CallScreenState.outgoing) { + _startOutgoingTimer(); + } + } + + void _startOutgoingTimer() { + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _seconds++); + if (_seconds >= 3 && _state == CallScreenState.outgoing) { + _timer?.cancel(); + setState(() => _state = CallScreenState.active); + _startActiveTimer(); + } + }); + } + + void _startActiveTimer() { + _seconds = 0; + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _seconds++); + }); + } + + String get _timerText { + final m = (_seconds ~/ 60).toString().padLeft(2, '0'); + final s = (_seconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + void _accept() { + setState(() { + _state = CallScreenState.active; + _seconds = 0; + }); + _startActiveTimer(); + } + + void _endCall() { + _timer?.cancel(); + Navigator.pop(context); + } + + @override + void dispose() { + _timer?.cancel(); + _pulseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenH = MediaQuery.of(context).size.height; + + return Scaffold( + backgroundColor: const Color(0xFF0E0E14), + body: SafeArea( + child: Column( + children: [ + const Spacer(flex: 3), + _buildAvatar(screenH), + const SizedBox(height: 24), + _buildName(), + const SizedBox(height: 8), + _buildStatus(), + const Spacer(flex: 2), + _buildActions(), + const SizedBox(height: 48), + ], + ), + ), + ); + } + + Widget _buildAvatar(double screenH) { + final size = screenH * 0.18; + final cs = Theme.of(context).colorScheme; + final isRinging = _state == CallScreenState.incoming; + final isOutgoing = _state == CallScreenState.outgoing; + + return AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + final scale = (isRinging || isOutgoing) + ? _pulseAnimation.value + : 1.0; + return Transform.scale( + scale: scale, + child: child, + ); + }, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primaryContainer.withValues(alpha: 0.2), + border: Border.all( + color: cs.primary.withValues(alpha: 0.3), + width: 2, + ), + ), + child: ClipOval( + child: widget.avatarUrl != null && widget.avatarUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: widget.avatarUrl!, + fit: BoxFit.cover, + memCacheWidth: 360, + memCacheHeight: 360, + errorWidget: (_, _, _) => _fallbackAvatar(size), + ) + : _fallbackAvatar(size), + ), + ), + ); + } + + Widget _fallbackAvatar(double size) { + final cs = Theme.of(context).colorScheme; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primaryContainer, + ), + alignment: Alignment.center, + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: size * 0.4, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildName() { + final cs = Theme.of(context).colorScheme; + return Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 26, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ); + } + + Widget _buildStatus() { + final cs = Theme.of(context).colorScheme; + String text; + switch (_state) { + case CallScreenState.incoming: + text = 'Входящий звонок'; + case CallScreenState.outgoing: + text = 'Вызов...'; + case CallScreenState.active: + text = _timerText; + } + return Text( + text, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ); + } + + Widget _buildActions() { + switch (_state) { + case CallScreenState.incoming: + return _buildIncomingActions(); + case CallScreenState.outgoing: + return _buildOutgoingActions(); + case CallScreenState.active: + return _buildActiveActions(); + } + } + + Widget _buildIncomingActions() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _ActionButton( + icon: Symbols.phone_disabled, + label: 'Отклонить', + color: const Color(0xFFBA1A1A), + onTap: _endCall, + ), + const SizedBox(width: 48), + _ActionButton( + icon: Symbols.phone, + label: 'Принять', + color: const Color(0xFF3A691E), + onTap: _accept, + ), + ], + ); + } + + Widget _buildOutgoingActions() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _ActionButton( + icon: Symbols.phone_disabled, + label: 'Отмена', + color: const Color(0xFFBA1A1A), + onTap: _endCall, + ), + ], + ); + } + + Widget _buildActiveActions() { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _CircleActionButton( + icon: _isMuted ? Symbols.mic_off : Symbols.mic, + active: _isMuted, + onTap: () => setState(() => _isMuted = !_isMuted), + ), + const SizedBox(width: 32), + _CircleActionButton( + icon: _isMuted ? Symbols.volume_off : Symbols.volume_up, + active: _isSpeaker, + onTap: () => setState(() => _isSpeaker = !_isSpeaker), + ), + const SizedBox(width: 32), + _CircleActionButton( + icon: Symbols.bluetooth_audio, + active: false, + onTap: () {}, + ), + ], + ), + const SizedBox(height: 40), + _ActionButton( + icon: Symbols.phone_disabled, + label: 'Завершить', + color: const Color(0xFFBA1A1A), + onTap: _endCall, + ), + ], + ); + } +} + +class _ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + const _ActionButton({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + alignment: Alignment.center, + child: Icon(icon, color: Colors.white, size: 28, fill: 1), + ), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +class _CircleActionButton extends StatelessWidget { + final IconData icon; + final bool active; + final VoidCallback onTap; + + const _CircleActionButton({ + required this.icon, + required this.active, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active + ? Colors.white.withValues(alpha: 0.2) + : Colors.white.withValues(alpha: 0.1), + ), + alignment: Alignment.center, + child: Icon( + icon, + color: active ? Colors.white : Colors.white70, + size: 24, + fill: 1, + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 25ba06a..ddd75c1 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1203,7 +1203,7 @@ class _ChatListScreenState extends State ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), child: Container( height: 44, decoration: BoxDecoration( @@ -1356,7 +1356,7 @@ class _ChatListScreenState extends State parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ - const SliverToBoxAdapter(child: SizedBox(height: 14)), + const SliverToBoxAdapter(child: SizedBox(height: 8)), if (chats.isEmpty && !_isInitialLoading) SliverFillRemaining( child: Center( @@ -1866,9 +1866,7 @@ class _ChatListScreenState extends State onPressed: _toggleFab, backgroundColor: cs.primaryContainer, elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), + shape: const CircleBorder(), child: Transform.rotate( angle: val * (pi / 4), child: Icon( @@ -2074,7 +2072,7 @@ class _ChatListScreenState extends State padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(50), ), child: Text( title, @@ -2372,6 +2370,7 @@ class _ChatListScreenState extends State icon, color: isSelected ? cs.onPrimary : cs.onSurface, size: 20, + fill: 1, ), AnimatedContainer( duration: animDur, diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 3f6b5f0..c215493 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -14,6 +14,7 @@ import '../../../core/utils/media_cache.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; +import '../calls/call_screen.dart'; class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); @@ -794,6 +795,91 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Экран звонка', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + 'Превью экранов звонков', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _DebugCallButton( + label: 'Входящий', + icon: Symbols.call_received, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen( + name: 'Кирил Г.', + initialState: CallScreenState.incoming, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DebugCallButton( + label: 'Исходящий', + icon: Symbols.call_made, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen( + name: 'Кирил Г.', + initialState: CallScreenState.outgoing, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DebugCallButton( + label: 'Активный', + icon: Symbols.phone_in_talk, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen( + name: 'Кирил Г.', + initialState: CallScreenState.active, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -1210,4 +1296,47 @@ class _ErrorChip extends StatelessWidget { ), ); } +} + +class _DebugCallButton extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onTap; + + const _DebugCallButton({ + required this.label, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, fill: 1), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } } \ No newline at end of file From c9db686c2add6c31dad870d04da98ae8a80801f9 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 3 Jun 2026 18:17:32 +0700 Subject: [PATCH 186/323] =?UTF-8?q?=D0=BD=D0=B5=D0=BA=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D0=BB=20=D0=B5=D0=B1=D0=B5=D0=BD?= =?UTF-8?q?=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/code_confirmation_screen.dart | 13 +- .../screens/auth/password_2fa_screen.dart | 13 +- .../widgets/login_success_screen.dart | 205 ++++++------------ 3 files changed, 65 insertions(+), 166 deletions(-) diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 0155fe4..5724ef0 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -193,18 +193,7 @@ class _CodeConfirmationScreenState extends State if (!mounted) return; - Navigator.pushAndRemoveUntil( - context, - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 240), - pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), - transitionsBuilder: (_, animation, __, child) => FadeTransition( - opacity: animation, - child: child, - ), - ), - (route) => false, - ); + showLoginSuccess(context, avatar: avatar); } catch (e) { if (!mounted) return; _showError(e.toString()); diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index a6716b0..0b54ece 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -51,18 +51,7 @@ class _Password2FAScreenState extends State { if (!mounted) return; - Navigator.pushAndRemoveUntil( - context, - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 240), - pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), - transitionsBuilder: (_, animation, __, child) => FadeTransition( - opacity: animation, - child: child, - ), - ), - (route) => false, - ); + showLoginSuccess(context, avatar: avatar); } catch (e) { if (!mounted) return; diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index 9cc9d93..67498e3 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -20,6 +20,30 @@ Future precacheLoginAvatar( } } +void showLoginSuccess(BuildContext context, {ImageProvider? avatar}) { + final navigator = Navigator.of(context); + navigator.pushAndRemoveUntil( + PageRouteBuilder( + transitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const AdaptiveShell(), + ), + (route) => false, + ); + navigator.push( + PageRouteBuilder( + opaque: false, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 240), + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => LoginSuccessScreen(avatar: avatar), + transitionsBuilder: (_, animation, _, child) => FadeTransition( + opacity: animation, + child: child, + ), + ), + ); +} + class LoginSuccessScreen extends StatefulWidget { final ImageProvider? avatar; @@ -32,7 +56,7 @@ class LoginSuccessScreen extends StatefulWidget { } class _LoginSuccessScreenState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { static const Duration _duration = Duration(milliseconds: 1900); static const List _greetings = [ @@ -55,9 +79,11 @@ class _LoginSuccessScreenState extends State late final Animation _subtitleOffset; late final Animation _fadeOut; + late final AnimationController _exitController; + late final Animation _bgFadeOut; + late final Animation _greetingFadeOut; + bool _navigated = false; - bool _handedOff = false; - final GlobalKey _subtitleKey = GlobalKey(); @override void initState() { @@ -110,7 +136,21 @@ class _LoginSuccessScreenState extends State curve: const Interval(0.88, 1.0, curve: Curves.easeInCubic), ); + _exitController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + _bgFadeOut = CurvedAnimation( + parent: _exitController, + curve: const Interval(0.0, 0.3, curve: Curves.easeOut), + ); + _greetingFadeOut = CurvedAnimation( + parent: _exitController, + curve: const Interval(0.55, 1.0, curve: Curves.easeInCubic), + ); + _controller.addStatusListener(_onStatus); + _exitController.addStatusListener(_onExitStatus); _controller.forward(); Haptics.success(); } @@ -118,82 +158,26 @@ class _LoginSuccessScreenState extends State void _onStatus(AnimationStatus status) { if (status == AnimationStatus.completed && !_navigated && mounted) { _navigated = true; - - final navigator = Navigator.of(context); - final overlay = navigator.overlay; - final entry = _buildGreetingEntry(); - if (entry != null) { - setState(() => _handedOff = true); - } - if (widget.preview) { - navigator.pop(); + Navigator.of(context).pop(); } else { - navigator.pushAndRemoveUntil( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 360), - reverseTransitionDuration: const Duration(milliseconds: 200), - pageBuilder: (_, _, _) => const AdaptiveShell(), - transitionsBuilder: (_, animation, _, child) { - return FadeTransition( - opacity: CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - ), - child: child, - ); - }, - ), - (route) => false, - ); - } - - if (entry != null && overlay != null) { - overlay.insert(entry); + _exitController.forward(); } } } - OverlayEntry? _buildGreetingEntry() { - final subtitleContext = _subtitleKey.currentContext; - if (subtitleContext == null) return null; - final box = subtitleContext.findRenderObject() as RenderBox?; - if (box == null || !box.attached) return null; - - final topLeft = box.localToGlobal(Offset.zero); - final size = box.size; - final style = DefaultTextStyle.of(subtitleContext) - .style - .merge(const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - height: 1.3, - )) - .copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - decoration: TextDecoration.none, - ); - - late final OverlayEntry entry; - entry = OverlayEntry( - builder: (_) => Positioned( - left: topLeft.dx, - top: topLeft.dy, - width: size.width, - child: _GreetingLinger( - greeting: _greeting, - style: style, - onDone: () => entry.remove(), - ), - ), - ); - return entry; + void _onExitStatus(AnimationStatus status) { + if (status == AnimationStatus.completed && mounted) { + Navigator.of(context).pop(); + } } @override void dispose() { _controller.removeStatusListener(_onStatus); _controller.dispose(); + _exitController.removeStatusListener(_onExitStatus); + _exitController.dispose(); super.dispose(); } @@ -201,16 +185,24 @@ class _LoginSuccessScreenState extends State Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Scaffold( - backgroundColor: cs.surface, + backgroundColor: Colors.transparent, body: AnimatedBuilder( - animation: _controller, + animation: Listenable.merge([_controller, _exitController]), builder: (context, _) { final celebrationOpacity = 1.0 - _fadeOut.value; + final bgOpacity = 1.0 - _bgFadeOut.value; + final greetingOpacity = 1.0 - _greetingFadeOut.value; return Stack( children: [ Positioned.fill( child: Opacity( - opacity: celebrationOpacity, + opacity: bgOpacity, + child: ColoredBox(color: cs.surface), + ), + ), + Positioned.fill( + child: Opacity( + opacity: celebrationOpacity * bgOpacity, child: DecoratedBox( decoration: BoxDecoration( gradient: RadialGradient( @@ -254,7 +246,7 @@ class _LoginSuccessScreenState extends State ), const SizedBox(height: 8), Opacity( - opacity: _handedOff ? 0.0 : 1.0, + opacity: greetingOpacity, child: _buildSubtitle(cs), ), ], @@ -381,7 +373,6 @@ class _LoginSuccessScreenState extends State padding: const EdgeInsets.symmetric(horizontal: 40), child: Text( _greeting, - key: _subtitleKey, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurfaceVariant, @@ -396,76 +387,6 @@ class _LoginSuccessScreenState extends State } } -class _GreetingLinger extends StatefulWidget { - final String greeting; - final TextStyle style; - final VoidCallback onDone; - - const _GreetingLinger({ - required this.greeting, - required this.style, - required this.onDone, - }); - - @override - State<_GreetingLinger> createState() => _GreetingLingerState(); -} - -class _GreetingLingerState extends State<_GreetingLinger> - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - late final Animation _opacity; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1200), - ); - _opacity = TweenSequence([ - TweenSequenceItem(tween: ConstantTween(1.0), weight: 55), - TweenSequenceItem( - tween: Tween(begin: 1.0, end: 0.0) - .chain(CurveTween(curve: Curves.easeInCubic)), - weight: 45, - ), - ]).animate(_controller); - _controller.addStatusListener((status) { - if (status == AnimationStatus.completed) widget.onDone(); - }); - _controller.forward(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return IgnorePointer( - child: Material( - type: MaterialType.transparency, - child: AnimatedBuilder( - animation: _opacity, - builder: (context, _) { - return Opacity( - opacity: _opacity.value, - child: Text( - widget.greeting, - textAlign: TextAlign.center, - style: widget.style, - ), - ); - }, - ), - ), - ); - } -} - class _CheckPainter extends CustomPainter { final double progress; final Color color; From e207d236d3024c20d82945469e2e83e918433e32 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Wed, 3 Jun 2026 18:27:22 +0700 Subject: [PATCH 187/323] =?UTF-8?q?=D0=BF=D0=BE=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=81=D0=BF=D0=B5=D1=80=D0=BC=D0=BE=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D1=81=D0=B8=D0=BA=D0=BE=D0=B7=D0=BD=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=BE=20=D0=B1=D1=83=D1=80=D0=BC=D0=B0=D0=BB=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=BE=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/login_success_screen.dart | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index 67498e3..fae5a6b 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -33,13 +33,9 @@ void showLoginSuccess(BuildContext context, {ImageProvider? avatar}) { PageRouteBuilder( opaque: false, barrierColor: Colors.transparent, - transitionDuration: const Duration(milliseconds: 240), + transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, pageBuilder: (_, _, _) => LoginSuccessScreen(avatar: avatar), - transitionsBuilder: (_, animation, _, child) => FadeTransition( - opacity: animation, - child: child, - ), ), ); } From 09fdb198549b963dc71ecdb7939de646e2f5b92f Mon Sep 17 00:00:00 2001 From: klockky Date: Wed, 3 Jun 2026 16:34:03 +0300 Subject: [PATCH 188/323] =?UTF-8?q?=D0=B6=D0=B8=D0=B6=D0=B0=20=D1=81=D1=83?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=B8=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=B3=D0=B8=D0=B2=D0=B0=D0=B9=D1=81=D1=8F=20=D0=BA=20?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8F=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/code_confirmation_screen.dart | 11 +- .../screens/auth/password_2fa_screen.dart | 11 +- .../screens/profile/debug_menu_screen.dart | 12 +- .../widgets/login_success_screen.dart | 127 ++++++------------ 4 files changed, 69 insertions(+), 92 deletions(-) diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 5724ef0..5d09d5f 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -193,7 +193,16 @@ class _CodeConfirmationScreenState extends State if (!mounted) return; - showLoginSuccess(context, avatar: avatar); + Navigator.pushAndRemoveUntil( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), + transitionsBuilder: (_, animation, __, child) => + FadeTransition(opacity: animation, child: child), + ), + (route) => false, + ); } catch (e) { if (!mounted) return; _showError(e.toString()); diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index 0b54ece..f64b970 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -51,7 +51,16 @@ class _Password2FAScreenState extends State { if (!mounted) return; - showLoginSuccess(context, avatar: avatar); + Navigator.pushAndRemoveUntil( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), + transitionsBuilder: (_, animation, __, child) => + FadeTransition(opacity: animation, child: child), + ), + (route) => false, + ); } catch (e) { if (!mounted) return; diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index c215493..f07c0e4 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -8,6 +8,7 @@ import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_stories.dart'; import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; +import '../../../core/storage/app_database.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/media_cache.dart'; @@ -736,12 +737,19 @@ class _DebugMenuScreenState extends State { borderRadius: BorderRadius.circular(20), child: InkWell( borderRadius: BorderRadius.circular(20), - onTap: () { + onTap: () async { + final profile = await AppDatabase.loadActiveProfile(); + if (!context.mounted) return; + final avatar = await precacheLoginAvatar( + context, + profile?.baseUrl, + ); + if (!context.mounted) return; Navigator.push( context, MaterialPageRoute( builder: (_) => - const LoginSuccessScreen(preview: true), + LoginSuccessScreen(preview: true, avatar: avatar), ), ); }, diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index fae5a6b..e8d8675 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -20,29 +20,8 @@ Future precacheLoginAvatar( } } -void showLoginSuccess(BuildContext context, {ImageProvider? avatar}) { - final navigator = Navigator.of(context); - navigator.pushAndRemoveUntil( - PageRouteBuilder( - transitionDuration: Duration.zero, - pageBuilder: (_, _, _) => const AdaptiveShell(), - ), - (route) => false, - ); - navigator.push( - PageRouteBuilder( - opaque: false, - barrierColor: Colors.transparent, - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - pageBuilder: (_, _, _) => LoginSuccessScreen(avatar: avatar), - ), - ); -} - class LoginSuccessScreen extends StatefulWidget { final ImageProvider? avatar; - final bool preview; const LoginSuccessScreen({super.key, this.avatar, this.preview = false}); @@ -52,7 +31,7 @@ class LoginSuccessScreen extends StatefulWidget { } class _LoginSuccessScreenState extends State - with TickerProviderStateMixin { + with SingleTickerProviderStateMixin { static const Duration _duration = Duration(milliseconds: 1900); static const List _greetings = [ @@ -75,10 +54,6 @@ class _LoginSuccessScreenState extends State late final Animation _subtitleOffset; late final Animation _fadeOut; - late final AnimationController _exitController; - late final Animation _bgFadeOut; - late final Animation _greetingFadeOut; - bool _navigated = false; @override @@ -132,21 +107,7 @@ class _LoginSuccessScreenState extends State curve: const Interval(0.88, 1.0, curve: Curves.easeInCubic), ); - _exitController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1200), - ); - _bgFadeOut = CurvedAnimation( - parent: _exitController, - curve: const Interval(0.0, 0.3, curve: Curves.easeOut), - ); - _greetingFadeOut = CurvedAnimation( - parent: _exitController, - curve: const Interval(0.55, 1.0, curve: Curves.easeInCubic), - ); - _controller.addStatusListener(_onStatus); - _exitController.addStatusListener(_onExitStatus); _controller.forward(); Haptics.success(); } @@ -156,15 +117,25 @@ class _LoginSuccessScreenState extends State _navigated = true; if (widget.preview) { Navigator.of(context).pop(); - } else { - _exitController.forward(); + return; } - } - } - - void _onExitStatus(AnimationStatus status) { - if (status == AnimationStatus.completed && mounted) { - Navigator.of(context).pop(); + Navigator.of(context).pushAndRemoveUntil( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 360), + reverseTransitionDuration: const Duration(milliseconds: 200), + pageBuilder: (_, __, ___) => const AdaptiveShell(), + transitionsBuilder: (_, animation, __, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + child: child, + ); + }, + ), + (route) => false, + ); } } @@ -172,8 +143,6 @@ class _LoginSuccessScreenState extends State void dispose() { _controller.removeStatusListener(_onStatus); _controller.dispose(); - _exitController.removeStatusListener(_onExitStatus); - _exitController.dispose(); super.dispose(); } @@ -181,24 +150,15 @@ class _LoginSuccessScreenState extends State Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Scaffold( - backgroundColor: Colors.transparent, + backgroundColor: cs.surface, body: AnimatedBuilder( - animation: Listenable.merge([_controller, _exitController]), + animation: _controller, builder: (context, _) { - final celebrationOpacity = 1.0 - _fadeOut.value; - final bgOpacity = 1.0 - _bgFadeOut.value; - final greetingOpacity = 1.0 - _greetingFadeOut.value; - return Stack( - children: [ - Positioned.fill( - child: Opacity( - opacity: bgOpacity, - child: ColoredBox(color: cs.surface), - ), - ), - Positioned.fill( - child: Opacity( - opacity: celebrationOpacity * bgOpacity, + return Opacity( + opacity: 1.0 - _fadeOut.value, + child: Stack( + children: [ + Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: RadialGradient( @@ -214,14 +174,11 @@ class _LoginSuccessScreenState extends State ), ), ), - ), - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Opacity( - opacity: celebrationOpacity, - child: SizedBox( + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( width: 220, height: 220, child: Stack( @@ -234,21 +191,15 @@ class _LoginSuccessScreenState extends State ], ), ), - ), - const SizedBox(height: 28), - Opacity( - opacity: celebrationOpacity, - child: _buildTitle(cs), - ), - const SizedBox(height: 8), - Opacity( - opacity: greetingOpacity, - child: _buildSubtitle(cs), - ), - ], + const SizedBox(height: 28), + _buildTitle(cs), + const SizedBox(height: 8), + _buildSubtitle(cs), + ], + ), ), - ), - ], + ], + ), ); }, ), From a2851b3681f04050892a95ab2561b77c451384c6 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 5 Jun 2026 13:11:28 +0700 Subject: [PATCH 189/323] =?UTF-8?q?=D0=92=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2=D0=BE=D0=B9=D1=82=D0=B8?= =?UTF-8?q?=20=D0=B2=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=BE=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F.=20=D0=92=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=20=D1=84=D0=B8=D0=BA=D1=81=20issue=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/transport/connection.dart | 24 ++++++++++++--------- lib/main.dart | 34 +++++++++++++++--------------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index 9aa8869..afb9dd9 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -13,6 +13,8 @@ enum SocketState { disconnected, connecting, connected } /// Обёртка над TCP + TLS сокетом. /// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. class Connection { + static const Duration _defaultConnectTimeout = Duration(seconds: 15); + SecureSocket? _socket; StreamSubscription? _subscription; SocketState _state = SocketState.disconnected; @@ -86,28 +88,30 @@ class Connection { ProxySettings proxySettings, { Duration? timeout, }) async { + final connectTimeout = timeout ?? _defaultConnectTimeout; Socket socket; if (proxySettings.isEnabled) { final connector = ProxyConnector(proxySettings); - socket = await connector.connect(host, port); + socket = await connector.connect(host, port).timeout(connectTimeout); logger.i('Подключено через прокси ${proxySettings.type.name}'); } else { - socket = timeout == null - ? await Socket.connect(host, port) - : await Socket.connect(host, port, timeout: timeout); + socket = await Socket.connect(host, port, timeout: connectTimeout); } final allowInsecure = await TlsConfig.isInsecureAllowed(); if (allowInsecure) { logger.w( 'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM', ); - return SecureSocket.secure( - socket, - host: host, - onBadCertificate: (_) => true, - ); } - return SecureSocket.secure(socket, host: host); + final secured = allowInsecure + ? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true) + : SecureSocket.secure(socket, host: host); + try { + return await secured.timeout(connectTimeout); + } on TimeoutException { + socket.destroy(); + rethrow; + } } void write(Uint8List data) { diff --git a/lib/main.dart b/lib/main.dart index 89b0cdf..88a8c36 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,13 +91,6 @@ void main() async { final storiesFuture = AppStories.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); - await api.connect(); - - final packageInfo = await packageInfoFuture; - if (packageInfo.packageName == 'ru.oneme.app') { - await PushService.instance.init(api: api, account: accountModule); - } - final initialLocale = await localeFuture; await hapticsFuture; @@ -135,6 +128,15 @@ void main() async { initialAccentSeed: initialAccentSeed, ), ); + + unawaited(_initPushIfNeeded(packageInfoFuture)); +} + +Future _initPushIfNeeded(Future packageInfoFuture) async { + final packageInfo = await packageInfoFuture; + if (packageInfo.packageName == 'ru.oneme.app') { + await PushService.instance.init(api: api, account: accountModule); + } } class KometApp extends StatefulWidget { @@ -714,27 +716,25 @@ class _StartupScreenState extends State<_StartupScreen> { } Future _tryAutoLogin() async { + unawaited(api.connect()); + int? accountId = await TokenStorage.getActiveAccountId(); if (accountId == null || await TokenStorage.readToken(accountId) == null) { accountId = await _recoverActiveAccount(); } + if (!mounted) return; + if (accountId == null) { _goToLogin(); return; } - try { - await accountModule.login(accountId: accountId); - } catch (_) {} - - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => const AdaptiveShell()), - ); - } + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const AdaptiveShell()), + ); } Future _recoverActiveAccount() async { From e954e0bc3bec341c49ac643ba341c08024ee67f4 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 5 Jun 2026 14:11:41 +0700 Subject: [PATCH 190/323] =?UTF-8?q?=D1=8F=20=D0=B1=D0=B0=D0=BB=D0=B1=D0=B5?= =?UTF-8?q?=D1=81,=20=D1=84=D0=B8=D0=BA=D1=81=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 88a8c36..a1b476e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,6 +91,11 @@ void main() async { final storiesFuture = AppStories.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); + final packageInfo = await packageInfoFuture; + if (packageInfo.packageName == 'ru.oneme.app') { + await PushService.instance.init(api: api, account: accountModule); + } + final initialLocale = await localeFuture; await hapticsFuture; @@ -128,15 +133,6 @@ void main() async { initialAccentSeed: initialAccentSeed, ), ); - - unawaited(_initPushIfNeeded(packageInfoFuture)); -} - -Future _initPushIfNeeded(Future packageInfoFuture) async { - final packageInfo = await packageInfoFuture; - if (packageInfo.packageName == 'ru.oneme.app') { - await PushService.instance.init(api: api, account: accountModule); - } } class KometApp extends StatefulWidget { From 2daf384dadd11669300477c706099490be7ff843 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 6 Jun 2026 19:36:24 +0300 Subject: [PATCH 191/323] fix(spoof): persist and apply all spoofing fields --- lib/backend/api.dart | 21 ++++++-- lib/core/storage/spoofing_service.dart | 8 ++- .../screens/profile/spoof_screen.dart | 49 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 4221c4f..a3fb613 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -179,6 +179,9 @@ class Api { String locale = 'ru'; String deviceLocale = Platform.localeName.substring(0, 2); String deviceId = await DeviceIdentity.deviceId(); + String pushDeviceType = 'GCM'; + String instanceId = await DeviceIdentity.instanceId(); + int clientSessionId = DeviceIdentity.clientSessionId; if (Platform.isLinux) { final linuxInfo = await deviceInfo.linuxInfo; @@ -223,6 +226,10 @@ class Api { locale = sLocale; deviceLocale = sLocale.split(RegExp(r'[-_]')).first; } + final sDeviceLocale = spoofed['device_locale'] as String?; + if (sDeviceLocale != null && sDeviceLocale.isNotEmpty) { + deviceLocale = sDeviceLocale; + } final sDeviceId = spoofed['device_id'] as String?; if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId; appVersion = (spoofed['app_version'] as String?) ?? appVersion; @@ -233,6 +240,14 @@ class Api { } else if (sBuild is String) { buildNumber = int.tryParse(sBuild) ?? buildNumber; } + final sPushType = spoofed['push_device_type'] as String?; + if (sPushType != null && sPushType.isNotEmpty) pushDeviceType = sPushType; + final sInstanceId = spoofed['instance_id'] as String?; + if (sInstanceId != null && sInstanceId.isNotEmpty) { + instanceId = sInstanceId; + } + final sClientSession = spoofed['client_session_id']; + if (sClientSession is int) clientSessionId = sClientSession; } _userAgent = { @@ -241,7 +256,7 @@ class Api { 'osVersion': osVersion, 'timezone': timezone, 'screen': screen, - 'pushDeviceType': 'GCM', + 'pushDeviceType': pushDeviceType, 'arch': architecture, 'locale': locale, 'buildNumber': buildNumber, @@ -252,9 +267,9 @@ class Api { _deviceId = deviceId; final payload = { - 'mt_instanceid': await DeviceIdentity.instanceId(), + 'mt_instanceid': instanceId, 'userAgent': _userAgent, - 'clientSessionId': DeviceIdentity.clientSessionId, + 'clientSessionId': clientSessionId, 'deviceId': deviceId, }; diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index b5e8c57..a51890b 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -16,11 +16,15 @@ class SpoofingService { 'screen': prefs.getString('spoof_screen'), 'timezone': prefs.getString('spoof_timezone'), 'locale': prefs.getString('spoof_locale'), + 'device_locale': prefs.getString('spoof_devicelocale'), 'device_id': prefs.getString('spoof_deviceid'), 'device_type': prefs.getString('spoof_devicetype'), - 'app_version': hardcodedAppVersion, + 'app_version': prefs.getString('spoof_appversion') ?? hardcodedAppVersion, 'arch': prefs.getString('spoof_arch') ?? 'arm64-v8a', - 'build_number': hardcodedBuildNumber, + 'build_number': prefs.getInt('spoof_buildnumber') ?? hardcodedBuildNumber, + 'instance_id': prefs.getString('spoof_instanceid'), + 'client_session_id': prefs.getInt('spoof_clientsessionid'), + 'push_device_type': prefs.getString('spoof_pushdevicetype'), }; } } diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index 9eef76d..48434c0 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -103,6 +103,21 @@ class _SpoofScreenState extends State { _buildNumberController.text = prefs.getInt('spoof_buildnumber')?.toString() ?? '$_hardcodedBuildNumber'; + _pushDeviceTypeController.text = + prefs.getString('spoof_pushdevicetype') ?? 'GCM'; + + final savedDeviceLocale = prefs.getString('spoof_devicelocale'); + if (savedDeviceLocale != null && savedDeviceLocale.isNotEmpty) { + _deviceLocaleController.text = savedDeviceLocale; + } + final savedInstanceId = prefs.getString('spoof_instanceid'); + if (savedInstanceId != null && savedInstanceId.isNotEmpty) { + _instanceIdController.text = savedInstanceId; + } + final savedClientSessionId = prefs.getInt('spoof_clientsessionid'); + if (savedClientSessionId != null) { + _clientSessionIdController.text = '$savedClientSessionId'; + } String savedType = prefs.getString('spoof_devicetype') ?? 'ANDROID'; if (savedType == 'WEB') savedType = 'ANDROID'; @@ -235,6 +250,13 @@ class _SpoofScreenState extends State { 'device_id': prefs.getString('spoof_deviceid') ?? '', 'device_type': prefs.getString('spoof_devicetype') ?? 'ANDROID', 'arch': prefs.getString('spoof_arch') ?? '', + 'device_locale': prefs.getString('spoof_devicelocale') ?? '', + 'app_version': prefs.getString('spoof_appversion') ?? '', + 'build_number': prefs.getInt('spoof_buildnumber')?.toString() ?? '', + 'push_device_type': prefs.getString('spoof_pushdevicetype') ?? '', + 'instance_id': prefs.getString('spoof_instanceid') ?? '', + 'client_session_id': + prefs.getInt('spoof_clientsessionid')?.toString() ?? '', }; final newValues = { @@ -246,6 +268,12 @@ class _SpoofScreenState extends State { 'device_id': _deviceIdController.text, 'device_type': _selectedDeviceType, 'arch': _selectedArch, + 'device_locale': _deviceLocaleController.text, + 'app_version': _appVersionController.text, + 'build_number': _buildNumberController.text, + 'push_device_type': _pushDeviceTypeController.text, + 'instance_id': _instanceIdController.text, + 'client_session_id': _clientSessionIdController.text, }; bool otherDataChanged = false; @@ -358,6 +386,27 @@ class _SpoofScreenState extends State { await prefs.setString('spoof_deviceid', _deviceIdController.text); await prefs.setString('spoof_devicetype', _selectedDeviceType); await prefs.setString('spoof_arch', _selectedArch); + await prefs.setString('spoof_devicelocale', _deviceLocaleController.text); + await prefs.setString('spoof_appversion', _appVersionController.text); + await prefs.setString( + 'spoof_pushdevicetype', + _pushDeviceTypeController.text, + ); + await prefs.setString('spoof_instanceid', _instanceIdController.text); + + final buildNumber = int.tryParse(_buildNumberController.text); + if (buildNumber != null) { + await prefs.setInt('spoof_buildnumber', buildNumber); + } else { + await prefs.remove('spoof_buildnumber'); + } + + final clientSessionId = int.tryParse(_clientSessionIdController.text); + if (clientSessionId != null) { + await prefs.setInt('spoof_clientsessionid', clientSessionId); + } else { + await prefs.remove('spoof_clientsessionid'); + } } void _generateNewDeviceId() { From 5b9aaf7d7eb344cf7d3125d4620104e0513bbd42 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 7 Jun 2026 13:07:47 +0700 Subject: [PATCH 192/323] =?UTF-8?q?=D1=86=D1=8D=20=D1=84=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=20=D1=85=D1=83=D0=B9=D0=BD=D1=8E=D1=88=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D0=B0=D1=8F=20=D0=B3=D0=B4=D0=B5?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D1=8F=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 4 + lib/core/media/gallery_source.dart | 162 ++++ .../screens/chats/chat_list_screen.dart | 780 ++++++++---------- lib/frontend/screens/chats/chat_screen.dart | 20 +- .../widgets/attachment/attachment_sheet.dart | 701 ++++++++++++++++ lib/frontend/widgets/sliding_pill_nav.dart | 209 +++++ pubspec.lock | 16 +- pubspec.yaml | 1 + 8 files changed, 1445 insertions(+), 448 deletions(-) create mode 100644 lib/core/media/gallery_source.dart create mode 100644 lib/frontend/widgets/attachment/attachment_sheet.dart create mode 100644 lib/frontend/widgets/sliding_pill_nav.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2735665..4ab092f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + thumbnail(int size); + Future originFile(); +} + +abstract class GallerySource { + Future ensurePermission(); + Future> load({int limit}); + Future openSettings(); + Future manageAccess(); + + factory GallerySource.create() { + if (Platform.isAndroid || Platform.isIOS) { + return _PhotoManagerSource(); + } + return _DesktopGallerySource(); + } +} + +class _PhotoManagerSource implements GallerySource { + @override + Future ensurePermission() async { + final state = await PhotoManager.requestPermissionExtend(); + if (state.isAuth) return GalleryPermission.granted; + if (state.hasAccess) return GalleryPermission.limited; + return GalleryPermission.denied; + } + + @override + Future> load({int limit = 120}) async { + final paths = await PhotoManager.getAssetPathList( + type: RequestType.common, + onlyAll: true, + filterOption: FilterOptionGroup( + orders: const [ + OrderOption(type: OrderOptionType.createDate, asc: false), + ], + ), + ); + if (paths.isEmpty) return const []; + final assets = await paths.first.getAssetListRange(start: 0, end: limit); + return assets.map((a) => _AssetGalleryItem(a)).toList(); + } + + @override + Future openSettings() => PhotoManager.openSetting(); + + @override + Future manageAccess() => PhotoManager.presentLimited(); +} + +class _AssetGalleryItem implements GalleryItem { + final AssetEntity asset; + + _AssetGalleryItem(this.asset); + + @override + String get id => asset.id; + + @override + bool get isVideo => asset.type == AssetType.video; + + @override + Duration? get duration => isVideo ? Duration(seconds: asset.duration) : null; + + @override + File? get localFile => null; + + @override + Future thumbnail(int size) => + asset.thumbnailDataWithSize(ThumbnailSize.square(size)); + + @override + Future originFile() => asset.file; +} + +class _DesktopGallerySource implements GallerySource { + static const _imageExtensions = { + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic', '.heif', + }; + + @override + Future ensurePermission() async => + GalleryPermission.granted; + + @override + Future> load({int limit = 120}) async { + final entries = <({File file, DateTime modified})>[]; + for (final dir in _candidateDirs()) { + if (!dir.existsSync()) continue; + try { + for (final entity in dir.listSync(followLinks: false)) { + if (entity is! File || !_isImage(entity.path)) continue; + entries.add((file: entity, modified: entity.statSync().modified)); + } + } catch (_) {} + } + entries.sort((a, b) => b.modified.compareTo(a.modified)); + return entries + .take(limit) + .map((e) => _FileGalleryItem(e.file)) + .toList(); + } + + @override + Future openSettings() async {} + + @override + Future manageAccess() async {} + + List _candidateDirs() { + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home == null || home.isEmpty) return const []; + return [ + Directory('$home/Pictures'), + Directory('$home/Изображения'), + Directory('$home/Images'), + ]; + } + + bool _isImage(String path) { + final dot = path.lastIndexOf('.'); + if (dot < 0) return false; + return _imageExtensions.contains(path.substring(dot).toLowerCase()); + } +} + +class _FileGalleryItem implements GalleryItem { + final File file; + + _FileGalleryItem(this.file); + + @override + String get id => file.path; + + @override + bool get isVideo => false; + + @override + Duration? get duration => null; + + @override + File? get localFile => file; + + @override + Future thumbnail(int size) async => null; + + @override + Future originFile() async => file; +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index ddd75c1..659fb9a 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -11,6 +11,7 @@ import 'create_group_flow.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/swipe_route.dart'; +import '../../widgets/sliding_pill_nav.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -82,6 +83,17 @@ class _ChatListScreenState extends State int _currentNavIndex = 0; + static const List _chatsNavItems = [ + PillNavItem(icon: Symbols.chat_bubble, label: 'Чаты'), + PillNavItem(icon: Symbols.call, label: 'Звонки'), + PillNavItem(icon: Symbols.person_pin, label: 'Контакты'), + PillNavItem( + icon: Symbols.settings, + label: 'Настройки', + longPressable: true, + ), + ]; + double _navPageAnimStart = 0; double _navPageAnimEnd = 0; final ValueNotifier _navDragDx = ValueNotifier(0); @@ -255,14 +267,20 @@ class _ChatListScreenState extends State final myId = _profile?.id; if (myId == null) return; - await ChatsModule.refreshChats(api, selectedBefore.map((c) => c.id).toList()); + await ChatsModule.refreshChats( + api, + selectedBefore.map((c) => c.id).toList(), + ); if (!mounted) return; final selectedAfter = _selectedChatObjects(); if (selectedAfter.isEmpty) return; final cats = selectedAfter.map((c) => _categorizeChat(c, myId)).toSet(); if (cats.contains(_DeleteKind.blocked) || cats.length > 1) { - showCustomNotification(context, 'Статус чатов изменился, попробуйте ещё раз'); + showCustomNotification( + context, + 'Статус чатов изменился, попробуйте ещё раз', + ); return; } final kind = cats.single; @@ -561,7 +579,9 @@ class _ChatListScreenState extends State if (mounted) { setState(() { _profile = p; - _chats = chats.where((c) => !CloudStorageModule.isCloudStorageGroup(c)).toList(); + _chats = chats + .where((c) => !CloudStorageModule.isCloudStorageGroup(c)) + .toList(); _folders = folders; _foldersListKnown = foldersKnown; if (_selectedFolderId != null && @@ -674,8 +694,10 @@ class _ChatListScreenState extends State final Map> _pageChatsCache = {}; List _chatsForPageIndex(int pageIndex) { - final baseKey = - Object.hash(identityHashCode(_chats), identityHashCode(_folders)); + final baseKey = Object.hash( + identityHashCode(_chats), + identityHashCode(_folders), + ); if (_pageChatsBaseKey != baseKey) { _pageChatsBaseKey = baseKey; _pageChatsCache.clear(); @@ -692,7 +714,9 @@ class _ChatListScreenState extends State final folder = _folders[pageIndex]; base = FoldersModule.isAllChatsFolder(folder) ? _chats - : _chats.where((c) => FoldersModule.chatMatchesFolder(c, folder)).toList(); + : _chats + .where((c) => FoldersModule.chatMatchesFolder(c, folder)) + .toList(); } final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList() ..sort((a, b) => a.favIndex!.compareTo(b.favIndex!)); @@ -1087,163 +1111,167 @@ class _ChatListScreenState extends State child: _shouldCollapseSearch ? const SizedBox(width: double.infinity, height: 52) : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 6, 20, 3), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - if (AppStories.current.value && - _pullRatio < 0.8) - Opacity( - opacity: 1.0 - _pullRatio, - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only(right: 8), - child: Stack( - children: [ - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=dasha', - 0, - ), - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=mastika', - 1, - ), - _buildFoldedStory( - cs, - 'https://i.pravatar.cc/150?u=stas', - 2, - ), - ], + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (AppStories.current.value && + _pullRatio < 0.8) + Opacity( + opacity: 1.0 - _pullRatio, + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only( + right: 8, + ), + child: Stack( + children: [ + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=dasha', + 0, + ), + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=mastika', + 1, + ), + _buildFoldedStory( + cs, + 'https://i.pravatar.cc/150?u=stas', + 2, + ), + ], + ), ), ), - ), - Text( - _sessionState == SessionState.online - ? (_profile?.firstName ?? 'Чат') - : 'Подключение...', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], - ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Кнопка 1', - Symbols.settings, - ), - _buildPopupMenuItem( - 2, - 'Кнопка 2', - Symbols.notifications, - ), - _buildPopupMenuItem( - 3, - 'Кнопка 3', - Symbols.shield, - ), - _buildPopupMenuItem( - 4, - 'Кнопка 4', - Symbols.info, - ), - ], - ), - ], - ), - ), - if (AppStories.current.value) - SizedBox( - height: 96 * _pullRatio, - child: Opacity( - opacity: _pullRatio, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, - ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), - child: Container( - height: 44, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, + Text( + _sessionState == SessionState.online + ? (_profile?.firstName ?? 'Чат') + : 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, ), + ], + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Кнопка 1', + Symbols.settings, + ), + _buildPopupMenuItem( + 2, + 'Кнопка 2', + Symbols.notifications, + ), + _buildPopupMenuItem( + 3, + 'Кнопка 3', + Symbols.shield, + ), + _buildPopupMenuItem( + 4, + 'Кнопка 4', + Symbols.info, + ), + ], ), ], ), ), - ), - ], - ), + if (AppStories.current.value) + SizedBox( + height: 96 * _pullRatio, + child: Opacity( + opacity: _pullRatio, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + children: [ + _buildStoryItem( + 'Даша', + 'https://i.pravatar.cc/150?u=dasha', + true, + ), + _buildStoryItem( + 'Мастика', + 'https://i.pravatar.cc/150?u=mastika', + false, + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), + child: Container( + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), + ), + ], + ), ), ), ), @@ -1388,11 +1416,15 @@ class _ChatListScreenState extends State ); } - final chatIndex = hasSeparator && index > pinnedCount ? index - 1 : index; + final chatIndex = hasSeparator && index > pinnedCount + ? index - 1 + : index; final chat = chats[chatIndex]; final isPinned = (chat.favIndex ?? 0) > 0; - if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { + if (chat.type.isNotEmpty && + chat.type == "DIALOG" && + chat.id != 0) { int secondId = _profile?.id ?? 0; for (final entry in chat.participants.entries) { if (entry.key != _profile?.id) { @@ -1404,7 +1436,8 @@ class _ChatListScreenState extends State final avatar = ContactCache.getAvatar(secondId); // ContactCache.isOfficial covers contacts loaded via opcode 32; // chat.isOfficial covers contacts from the login payload. - final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial; + final isVerified = + ContactCache.isOfficial(secondId) || chat.isOfficial; final isPlaceholder = chat.lastMsgText == ChatsModule.lastMsgPlaceholder; @@ -1531,34 +1564,15 @@ class _ChatListScreenState extends State double navInnerW, double bottomInset, ) { - final totalWeight = 5.2; - final unitWidth = navInnerW / totalWeight; - final activeWidth = unitWidth * 2.2; - final inactiveWidth = unitWidth * 1.0; + final geometry = PillNavGeometry.fromInnerWidth(navInnerW, 4); + final inactiveWidth = geometry.inactiveWidth; + final bubbleW = geometry.activeWidth - 8; - double bubbleLeftForIndex(int index) { - double lo = 0; - for (int i = 0; i < index; i++) { - lo += inactiveWidth; - } - return lo + 4; - } + double bubbleLeftForIndex(int index) => index * inactiveWidth + 4; - final leftOffset = bubbleLeftForIndex(_currentNavIndex); - final bubbleW = activeWidth - 8; final minBubbleLeft = bubbleLeftForIndex(0); final maxBubbleLeft = bubbleLeftForIndex(3); - double navInterpolatedWidth(int tabIndex, double rowT) { - final rt = rowT.clamp(0.0, 3.0); - final i0 = rt.floor().clamp(0, 3); - final i1 = rt.ceil().clamp(0, 3); - final frac = i0 == i1 ? 0.0 : (rt - i0); - double at(int sel, int tab) => - (tab == sel) ? (activeWidth - 0.5) : (inactiveWidth - 0.5); - return at(i0, tabIndex) + (at(i1, tabIndex) - at(i0, tabIndex)) * frac; - } - int indexForBubbleLeft(double left) { final cx = left + bubbleW / 2; var best = 0; @@ -1581,140 +1595,68 @@ class _ChatListScreenState extends State right: 8, bottom: _isSelectionMode ? -100 : bottomInset + 10.0, child: RepaintBoundary( - child: Container( - height: 68, - padding: const EdgeInsets.symmetric(horizontal: 2), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(34), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragStart: (_) { - if (_isSelectionMode) return; - _navPageAnimController.stop(); - _navPageAnimController.value = 1.0; - _navDragDx.value = 0; - setState(() { - _navDragging = true; - _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); - }); - }, - onHorizontalDragUpdate: (details) { - if (!_navDragging) return; - _navDragDx.value += details.delta.dx; - }, - onHorizontalDragEnd: (_) { - if (!_navDragging) return; - final left = (_navDragBaseLeft + _navDragDx.value).clamp( - minBubbleLeft, - maxBubbleLeft, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) { + if (_isSelectionMode) return; + _navPageAnimController.stop(); + _navPageAnimController.value = 1.0; + _navDragDx.value = 0; + setState(() { + _navDragging = true; + _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); + }); + }, + onHorizontalDragUpdate: (details) { + if (!_navDragging) return; + _navDragDx.value += details.delta.dx; + }, + onHorizontalDragEnd: (_) { + if (!_navDragging) return; + final left = (_navDragBaseLeft + _navDragDx.value).clamp( + minBubbleLeft, + maxBubbleLeft, + ); + final next = indexForBubbleLeft(left); + _navDragDx.value = 0; + setState(() { + _currentNavIndex = next; + _navDragging = false; + }); + }, + onHorizontalDragCancel: () { + if (!_navDragging) return; + _navDragDx.value = 0; + setState(() { + _navDragging = false; + }); + }, + child: ValueListenableBuilder( + valueListenable: _navDragDx, + builder: (context, navDragDx, _) { + final position = _navDragging + ? ((_navDragBaseLeft + navDragDx).clamp( + minBubbleLeft, + maxBubbleLeft, + ) - + 4) / + inactiveWidth + : _currentNavIndex.toDouble(); + return SlidingPillNav( + items: _chatsNavItems, + position: position, + animationDuration: _navDragging + ? Duration.zero + : const Duration(milliseconds: 350), + geometry: geometry, + iconSize: 20, + labelGap: 4, + onTap: _onNavTabSelected, + onItemLongPress: (index, pos) { + if (index == 3) _openAccountSwitcher(pos); + }, ); - final next = indexForBubbleLeft(left); - _navDragDx.value = 0; - setState(() { - _currentNavIndex = next; - _navDragging = false; - }); }, - onHorizontalDragCancel: () { - if (!_navDragging) return; - _navDragDx.value = 0; - setState(() { - _navDragging = false; - }); - }, - child: ValueListenableBuilder( - valueListenable: _navDragDx, - builder: (context, navDragDx, _) { - final bubbleLeft = _navDragging - ? (_navDragBaseLeft + navDragDx) - .clamp(minBubbleLeft, maxBubbleLeft) - : leftOffset; - final navRowT = - ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); - return Stack( - clipBehavior: Clip.hardEdge, - children: [ - AnimatedPositioned( - duration: _navDragging - ? Duration.zero - : const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - left: bubbleLeft, - top: 8, - bottom: 8, - width: bubbleW, - child: Container( - decoration: BoxDecoration( - color: cs.primary, - borderRadius: BorderRadius.circular(26), - ), - ), - ), - SizedBox( - width: navInnerW, - child: Row( - children: List.generate(4, (index) { - IconData icon; - String label; - switch (index) { - case 0: - icon = Symbols.chat_bubble; - label = 'Чаты'; - break; - case 1: - icon = Symbols.call; - label = 'Звонки'; - break; - case 2: - icon = Symbols.person_pin; - label = 'Контакты'; - break; - default: - icon = Symbols.settings; - label = 'Настройки'; - } - - final isSelected = _currentNavIndex == index; - final visualSel = navRowT.round().clamp(0, 3); - return AnimatedContainer( - duration: _navDragging - ? Duration.zero - : const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - width: _navDragging - ? navInterpolatedWidth(index, navRowT) - : (isSelected - ? (activeWidth - 0.5) - : (inactiveWidth - 0.5)), - child: ClipRRect( - borderRadius: BorderRadius.circular(26), - child: _buildNavItem( - index, - icon, - label, - selectedOverride: _navDragging - ? (index == visualSel) - : null, - instant: _navDragging, - ), - ), - ); - }), - ), - ), - ], - ); - }, - ), ), ), ), @@ -1759,8 +1701,10 @@ class _ChatListScreenState extends State width: pageW * 4, height: pageH, child: AnimatedBuilder( - animation: Listenable.merge( - [_navPageAnimController, _navDragDx]), + animation: Listenable.merge([ + _navPageAnimController, + _navDragDx, + ]), child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1902,53 +1846,64 @@ class _ChatListScreenState extends State ), ], ), - child: Builder(builder: (_) { - final selected = _selectedChatObjects(); - final deleteCategory = _selectionDeleteCategoryFor(selected); - final anyMuted = selected.any((c) => c.isMuted); - final anyPinned = selected.any((c) => (c.favIndex ?? 0) > 0); - return Row( - children: [ - IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: _clearSelection, - ), - const SizedBox(width: 8), - Text( - _selectedChats.length.toString(), - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - if (deleteCategory != null) + child: Builder( + builder: (_) { + final selected = _selectedChatObjects(); + final deleteCategory = _selectionDeleteCategoryFor( + selected, + ); + final anyMuted = selected.any((c) => c.isMuted); + final anyPinned = selected.any( + (c) => (c.favIndex ?? 0) > 0, + ); + return Row( + children: [ IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: _onDeleteTap, + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + ), + onPressed: _clearSelection, ), - IconButton( - icon: Icon(Symbols.archive, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon( - anyPinned ? Symbols.keep_off : Symbols.keep, - color: cs.onSurface, + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), ), - onPressed: selected.isEmpty ? null : _onPinTap, - ), - IconButton( - icon: Icon( - anyMuted ? Symbols.volume_up : Symbols.volume_off, - color: cs.onSurface, + const Spacer(), + if (deleteCategory != null) + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: _onDeleteTap, + ), + IconButton( + icon: Icon(Symbols.archive, color: cs.onSurface), + onPressed: () {}, ), - onPressed: selected.isEmpty ? null : _onMuteTap, - ), - ], - ); - }), + IconButton( + icon: Icon( + anyPinned ? Symbols.keep_off : Symbols.keep, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onPinTap, + ), + IconButton( + icon: Icon( + anyMuted + ? Symbols.volume_up + : Symbols.volume_off, + color: cs.onSurface, + ), + onPressed: selected.isEmpty ? null : _onMuteTap, + ), + ], + ); + }, + ), ), ), ], @@ -1981,7 +1936,11 @@ class _ChatListScreenState extends State ), child: CircleAvatar( radius: 26, - backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), + backgroundImage: CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), ), ), const SizedBox(height: 6), @@ -2114,18 +2073,26 @@ class _ChatListScreenState extends State return; } if (imageUrl.isNotEmpty) { - unawaited(precacheImage( - CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), - context, - )); + unawaited( + precacheImage( + CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + context, + ), + ); } if (widget.onChatSelected != null) { - widget.onChatSelected!(DesktopChatSelection( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - )); + widget.onChatSelected!( + DesktopChatSelection( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, + ), + ); } else { pushSwipeable( context, @@ -2155,7 +2122,11 @@ class _ChatListScreenState extends State radius: 24, backgroundColor: cs.surfaceContainerHighest, backgroundImage: imageUrl.isNotEmpty - ? CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144) + ? CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ) : null, child: imageUrl.isEmpty ? Text( @@ -2337,71 +2308,6 @@ class _ChatListScreenState extends State ); } - Widget _buildNavItem( - int index, - IconData icon, - String label, { - bool? selectedOverride, - bool instant = false, - }) { - final cs = Theme.of(context).colorScheme; - final bool isSelected = selectedOverride ?? (_currentNavIndex == index); - final Duration animDur = instant - ? Duration.zero - : const Duration(milliseconds: 350); - final Duration opacityDur = instant - ? Duration.zero - : const Duration(milliseconds: 200); - final bool isSettings = index == 3; - return GestureDetector( - onTap: () => _onNavTabSelected(index), - onLongPressStart: isSettings - ? (details) => _openAccountSwitcher(details.globalPosition) - : null, - behavior: HitTestBehavior.opaque, - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - color: isSelected ? cs.onPrimary : cs.onSurface, - size: 20, - fill: 1, - ), - AnimatedContainer( - duration: animDur, - curve: Curves.easeOutCubic, - width: isSelected ? null : 0, - child: AnimatedOpacity( - duration: opacityDur, - opacity: isSelected ? 1.0 : 0.0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - color: cs.onPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } - void _openAccountSwitcher(Offset point) { Haptics.medium(); final controller = AccountSwitcherController()..attach(point); @@ -2537,7 +2443,11 @@ class _ChatListScreenState extends State ), child: CircleAvatar( radius: 12, - backgroundImage: CachedNetworkImageProvider(imageUrl, maxWidth: 144, maxHeight: 144), + backgroundImage: CachedNetworkImageProvider( + imageUrl, + maxWidth: 144, + maxHeight: 144, + ), ), ), ); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 35981c2..c3fa1f5 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -29,6 +29,7 @@ import '../../widgets/message_bubble.dart'; import '../../widgets/theme_reveal.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; +import '../../widgets/attachment/attachment_sheet.dart'; import '../../widgets/swipe_to_pop.dart'; class _UploadStatus { @@ -1526,7 +1527,7 @@ class _ChatScreenState extends State ), _AttachButton( hasText: _hasText, - panelOpen: _showAttachmentPanel, + onOpen: _openAttachmentSheet, uploadStatus: _uploadStatus, mutedIcon: mutedIcon, cs: cs, @@ -1712,6 +1713,10 @@ class _ChatScreenState extends State } } + void _openAttachmentSheet() { + showAttachmentSheet(context); + } + Future _pickAndUploadFile() async { final result = await FilePicker.platform.pickFiles(); if (result == null || result.files.isEmpty) return; @@ -1820,14 +1825,14 @@ class _ChatScreenState extends State class _AttachButton extends StatelessWidget { final ValueNotifier hasText; - final ValueNotifier panelOpen; + final VoidCallback onOpen; final ValueNotifier<_UploadStatus> uploadStatus; final Color mutedIcon; final ColorScheme cs; const _AttachButton({ required this.hasText, - required this.panelOpen, + required this.onOpen, required this.uploadStatus, required this.mutedIcon, required this.cs, @@ -1836,19 +1841,16 @@ class _AttachButton extends StatelessWidget { @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: Listenable.merge([hasText, panelOpen, uploadStatus]), + listenable: Listenable.merge([hasText, uploadStatus]), builder: (context, _) { final isText = hasText.value; - final open = panelOpen.value; final status = uploadStatus.value; final iconColor = status.awaitingResponse ? cs.primary - : (status.active || open + : (status.active ? cs.onSurfaceVariant.withValues(alpha: 0.5) : mutedIcon); - final onTap = (isText || status.active || open) - ? null - : () => panelOpen.value = true; + final onTap = (isText || status.active) ? null : onOpen; return AnimatedContainer( duration: const Duration(milliseconds: 200), width: isText ? 0 : 36, diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart new file mode 100644 index 0000000..81d5bd6 --- /dev/null +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -0,0 +1,701 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/core/media/gallery_source.dart'; +import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; + +const List _navItems = [ + PillNavItem(icon: Symbols.image, label: 'Галерея'), + PillNavItem(icon: Symbols.description, label: 'Файл'), + PillNavItem(icon: Symbols.location_on, label: 'Геопозиция'), + PillNavItem(icon: Symbols.person, label: 'Контакт'), +]; + +Future showAttachmentSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withValues(alpha: 0.45), + builder: (_) => const AttachmentSheet(), + ); +} + +class AttachmentSheet extends StatefulWidget { + const AttachmentSheet({super.key}); + + @override + State createState() => _AttachmentSheetState(); +} + +class _AttachmentSheetState extends State { + final GallerySource _source = GallerySource.create(); + final ValueNotifier> _selected = ValueNotifier({}); + final PageController _pageController = PageController(); + + bool _navDragging = false; + double _navDragBasePageT = 0; + double _navDragAccumDx = 0; + + bool _loading = true; + GalleryPermission _permission = GalleryPermission.granted; + List _items = const []; + + @override + void initState() { + super.initState(); + _loadGallery(); + } + + @override + void dispose() { + _pageController.dispose(); + _selected.dispose(); + super.dispose(); + } + + Future _loadGallery() async { + setState(() => _loading = true); + final permission = await _source.ensurePermission(); + if (!mounted) return; + if (permission == GalleryPermission.denied) { + setState(() { + _permission = permission; + _items = const []; + _loading = false; + }); + return; + } + final items = await _source.load(limit: 120); + if (!mounted) return; + setState(() { + _permission = permission; + _items = items; + _loading = false; + }); + } + + void _toggleSelection(GalleryItem item) { + final next = Set.from(_selected.value); + if (!next.remove(item.id)) next.add(item.id); + _selected.value = next; + } + + void _onSectionTap(int index) { + _pageController.animateToPage( + index, + duration: _navAnim, + curve: Curves.easeOutCubic, + ); + } + + void _onCameraTap() { + showCustomNotification(context, 'Камера скоро появится'); + } + + void _onSend() { + final count = _selected.value.length; + final overlay = Overlay.of(context, rootOverlay: true); + Navigator.of(context).pop(); + showCustomNotificationOnOverlay( + overlay, + 'Отправка $count выбранных скоро появится', + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return DraggableScrollableSheet( + initialChildSize: 0.62, + minChildSize: 0.4, + maxChildSize: 0.94, + expand: false, + snap: true, + snapSizes: const [0.62, 0.94], + builder: (context, scrollController) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final barReserve = _barHeight + bottomInset; + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _buildHandle(cs), + Expanded( + child: Stack( + children: [ + _buildPages(scrollController, cs, barReserve), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildBottomBar(), + ), + Positioned( + right: 16, + bottom: barReserve + 8, + child: AnimatedBuilder( + animation: Listenable.merge([ + _selected, + _pageController, + ]), + builder: (context, _) { + final count = _selected.value.length; + final galleryT = (1 - _currentPageT()).clamp( + 0.0, + 1.0, + ); + if (count == 0 || galleryT == 0) { + return const SizedBox.shrink(); + } + return Opacity( + opacity: galleryT, + child: IgnorePointer( + ignoring: galleryT < 0.5, + child: _buildSendButton(cs, count), + ), + ); + }, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + static const double _pillMargin = 10; + static const double _barHeight = SlidingPillNav.height + _pillMargin; + static const Duration _navAnim = Duration(milliseconds: 300); + + Widget _buildHandle(ColorScheme cs) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 10), + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ); + } + + Widget _buildPages( + ScrollController scrollController, + ColorScheme cs, + double bottomReserve, + ) { + return PageView( + controller: _pageController, + children: [ + _KeepAlivePage( + child: _buildGalleryPage(scrollController, cs, bottomReserve), + ), + _buildPlaceholderPage(cs, bottomReserve), + _buildPlaceholderPage(cs, bottomReserve), + _buildPlaceholderPage(cs, bottomReserve), + ], + ); + } + + Widget _buildGalleryPage( + ScrollController scrollController, + ColorScheme cs, + double bottomReserve, + ) { + if (_loading) { + return Center(child: CircularProgressIndicator(color: cs.primary)); + } + if (_permission == GalleryPermission.denied) { + return _buildDenied(scrollController, cs, bottomReserve); + } + if (_items.isEmpty) { + return _buildMessage( + scrollController, + cs, + 'Изображений не найдено', + bottomReserve, + ); + } + + return CustomScrollView( + controller: scrollController, + slivers: [ + if (_permission == GalleryPermission.limited) + SliverToBoxAdapter(child: _buildLimitedBanner(cs)), + SliverPadding( + padding: EdgeInsets.fromLTRB(2, 2, 2, bottomReserve + 6), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + ), + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 0) return _CameraTile(onTap: _onCameraTap, cs: cs); + final item = _items[index - 1]; + return _GalleryTile( + key: ValueKey(item.id), + item: item, + selectedIds: _selected, + onTap: () => _toggleSelection(item), + cs: cs, + ); + }, childCount: _items.length + 1), + ), + ), + ], + ); + } + + Widget _buildLimitedBanner(ColorScheme cs) { + return InkWell( + onTap: () => _source.manageAccess().then((_) => _loadGallery()), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: cs.surfaceContainerHighest, + child: Row( + children: [ + Icon(Symbols.info, size: 18, color: cs.onSurfaceVariant), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Доступны не все фото', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + Text( + 'Изменить', + style: TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } + + Widget _buildPlaceholderPage(ColorScheme cs, double bottomReserve) { + return Padding( + padding: EdgeInsets.only(bottom: bottomReserve), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.construction, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 12), + Text( + 'Раздел в разработке', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), + ], + ), + ), + ); + } + + Widget _buildDenied( + ScrollController scrollController, + ColorScheme cs, + double bottomReserve, + ) { + return _scrollableCenter( + scrollController, + bottomReserve, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.no_photography, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 12), + Text( + 'Нет доступа к галерее', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + const SizedBox(height: 4), + Text( + 'Разрешите доступ к фото, чтобы выбрать их отсюда', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 16), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: _loadGallery, + child: const Text('Разрешить'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => _source.openSettings(), + child: const Text('Настройки'), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildMessage( + ScrollController scrollController, + ColorScheme cs, + String text, + double bottomReserve, + ) { + return _scrollableCenter( + scrollController, + bottomReserve, + Text(text, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)), + ); + } + + Widget _scrollableCenter( + ScrollController scrollController, + double bottomReserve, + Widget child, + ) { + return CustomScrollView( + controller: scrollController, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: EdgeInsets.only(bottom: bottomReserve), + child: Center(child: child), + ), + ), + ], + ); + } + + Widget _buildSendButton(ColorScheme cs, int count) { + return Material( + color: cs.primary, + shape: const StadiumBorder(), + elevation: 3, + child: InkWell( + customBorder: const StadiumBorder(), + onTap: _onSend, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.send, color: cs.onPrimary, size: 22, weight: 500), + const SizedBox(width: 8), + Text( + '$count', + style: TextStyle( + color: cs.onPrimary, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } + + double _currentPageT() { + if (!_pageController.hasClients) return 0; + return _pageController.page ?? 0; + } + + void _onPillDragStart() { + _navDragging = true; + _navDragBasePageT = _currentPageT(); + _navDragAccumDx = 0; + } + + void _onPillDragUpdate(double dx, double inactiveWidth) { + if (!_navDragging || !_pageController.hasClients) return; + _navDragAccumDx += dx; + final pageT = (_navDragBasePageT + _navDragAccumDx / inactiveWidth).clamp( + 0.0, + 3.0, + ); + _pageController.jumpTo(pageT * _pageController.position.viewportDimension); + } + + void _onPillDragEnd() { + if (!_navDragging) return; + _navDragging = false; + final target = _currentPageT().round().clamp(0, 3); + _pageController.animateToPage( + target, + duration: _navAnim, + curve: Curves.easeOutCubic, + ); + } + + Widget _buildBottomBar() { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, _pillMargin), + child: LayoutBuilder( + builder: (context, constraints) { + final geometry = PillNavGeometry.fromInnerWidth( + constraints.maxWidth - 4, + _navItems.length, + ); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) => _onPillDragStart(), + onHorizontalDragUpdate: (d) => + _onPillDragUpdate(d.delta.dx, geometry.inactiveWidth), + onHorizontalDragEnd: (_) => _onPillDragEnd(), + onHorizontalDragCancel: _onPillDragEnd, + child: AnimatedBuilder( + animation: _pageController, + builder: (context, _) { + return SlidingPillNav( + items: _navItems, + position: _currentPageT(), + geometry: geometry, + onTap: _onSectionTap, + ); + }, + ), + ); + }, + ), + ), + ); + } +} + +class _KeepAlivePage extends StatefulWidget { + final Widget child; + + const _KeepAlivePage({required this.child}); + + @override + State<_KeepAlivePage> createState() => _KeepAlivePageState(); +} + +class _KeepAlivePageState extends State<_KeepAlivePage> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} + +class _CameraTile extends StatelessWidget { + final VoidCallback onTap; + final ColorScheme cs; + + const _CameraTile({required this.onTap, required this.cs}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + color: cs.surfaceContainerHighest, + alignment: Alignment.center, + child: Icon( + Symbols.photo_camera, + size: 34, + color: cs.onSurface, + weight: 400, + ), + ), + ); + } +} + +class _GalleryTile extends StatefulWidget { + final GalleryItem item; + final ValueListenable> selectedIds; + final VoidCallback onTap; + final ColorScheme cs; + + const _GalleryTile({ + super.key, + required this.item, + required this.selectedIds, + required this.onTap, + required this.cs, + }); + + @override + State<_GalleryTile> createState() => _GalleryTileState(); +} + +class _GalleryTileState extends State<_GalleryTile> { + late bool _selected; + + @override + void initState() { + super.initState(); + _selected = widget.selectedIds.value.contains(widget.item.id); + widget.selectedIds.addListener(_onSelectionChanged); + } + + @override + void dispose() { + widget.selectedIds.removeListener(_onSelectionChanged); + super.dispose(); + } + + void _onSelectionChanged() { + final selected = widget.selectedIds.value.contains(widget.item.id); + if (selected != _selected) setState(() => _selected = selected); + } + + @override + Widget build(BuildContext context) { + final item = widget.item; + return GestureDetector( + onTap: widget.onTap, + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedScale( + scale: _selected ? 0.86 : 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: _Thumbnail(item: item, cs: widget.cs), + ), + if (item.isVideo) + Positioned( + left: 6, + bottom: 6, + child: Row( + children: [ + Icon( + Symbols.play_arrow, + size: 16, + color: Colors.white, + fill: 1, + ), + if (item.duration != null) + Text( + _formatDuration(item.duration!), + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + shadows: [Shadow(blurRadius: 3, color: Colors.black54)], + ), + ), + ], + ), + ), + Positioned( + top: 6, + right: 6, + child: _SelectionCheck(selected: _selected, cs: widget.cs), + ), + ], + ), + ); + } + + String _formatDuration(Duration d) { + final m = d.inMinutes; + final s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } +} + +class _SelectionCheck extends StatelessWidget { + final bool selected; + final ColorScheme cs; + + const _SelectionCheck({required this.selected, required this.cs}); + + @override + Widget build(BuildContext context) { + return Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? cs.primary : Colors.black.withValues(alpha: 0.25), + border: Border.all(color: Colors.white, width: 2), + ), + child: selected + ? Icon(Symbols.check, size: 16, color: cs.onPrimary, weight: 700) + : null, + ); + } +} + +class _Thumbnail extends StatefulWidget { + final GalleryItem item; + final ColorScheme cs; + + const _Thumbnail({required this.item, required this.cs}); + + @override + State<_Thumbnail> createState() => _ThumbnailState(); +} + +class _ThumbnailState extends State<_Thumbnail> { + static const int _pixelSize = 320; + Future? _future; + + @override + void initState() { + super.initState(); + if (widget.item.localFile == null) { + _future = widget.item.thumbnail(_pixelSize); + } + } + + @override + Widget build(BuildContext context) { + final file = widget.item.localFile; + if (file != null) { + return Image.file( + file, + fit: BoxFit.cover, + cacheWidth: _pixelSize, + gaplessPlayback: true, + errorBuilder: (_, _, _) => _placeholder(), + ); + } + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + final data = snapshot.data; + if (data == null) return _placeholder(); + return Image.memory( + data, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => _placeholder(), + ); + }, + ); + } + + Widget _placeholder() => ColoredBox(color: widget.cs.surfaceContainerHighest); +} diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart new file mode 100644 index 0000000..def6650 --- /dev/null +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; + +class PillNavItem { + final IconData icon; + final String label; + final bool longPressable; + + const PillNavItem({ + required this.icon, + required this.label, + this.longPressable = false, + }); +} + +class PillNavGeometry { + final double navInnerW; + final double activeWidth; + final double inactiveWidth; + + const PillNavGeometry(this.navInnerW, this.activeWidth, this.inactiveWidth); + + factory PillNavGeometry.fromInnerWidth(double navInnerW, int itemCount) { + final totalWeight = (itemCount - 1) + _activeWeight; + final unit = navInnerW / totalWeight; + return PillNavGeometry(navInnerW, unit * _activeWeight, unit); + } + + static const double _activeWeight = 2.2; +} + +class SlidingPillNav extends StatelessWidget { + final List items; + final double position; + final Duration animationDuration; + final PillNavGeometry geometry; + final ValueChanged onTap; + final void Function(int index, Offset globalPosition)? onItemLongPress; + final double iconSize; + final double labelGap; + + const SlidingPillNav({ + super.key, + required this.items, + required this.position, + required this.geometry, + required this.onTap, + this.animationDuration = Duration.zero, + this.onItemLongPress, + this.iconSize = 22, + this.labelGap = 6, + }); + + static const double height = 68; + + double _interpWidth(int tab) { + final maxIndex = items.length - 1; + final rt = position.clamp(0.0, maxIndex.toDouble()); + final i0 = rt.floor(); + final i1 = rt.ceil(); + final frac = i0 == i1 ? 0.0 : rt - i0; + double at(int sel) => + (tab == sel ? geometry.activeWidth : geometry.inactiveWidth) - 0.5; + return at(i0) + (at(i1) - at(i0)) * frac; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final visualSel = position.round().clamp(0, items.length - 1); + return Container( + height: height, + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(34), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + AnimatedPositioned( + duration: animationDuration, + curve: Curves.easeOutCubic, + left: position * geometry.inactiveWidth + 4, + top: 8, + bottom: 8, + width: geometry.activeWidth - 8, + child: DecoratedBox( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(26), + ), + ), + ), + SizedBox( + width: geometry.navInnerW, + child: Row( + children: List.generate(items.length, (i) { + return AnimatedContainer( + duration: animationDuration, + curve: Curves.easeOutCubic, + width: _interpWidth(i), + child: ClipRRect( + borderRadius: BorderRadius.circular(26), + child: _PillNavCell( + item: items[i], + selected: i == visualSel, + cs: cs, + animationDuration: animationDuration, + iconSize: iconSize, + labelGap: labelGap, + onTap: () => onTap(i), + onLongPress: + (onItemLongPress == null || !items[i].longPressable) + ? null + : (pos) => onItemLongPress!(i, pos), + ), + ), + ); + }), + ), + ), + ], + ), + ); + } +} + +class _PillNavCell extends StatelessWidget { + final PillNavItem item; + final bool selected; + final ColorScheme cs; + final Duration animationDuration; + final double iconSize; + final double labelGap; + final VoidCallback onTap; + final void Function(Offset globalPosition)? onLongPress; + + const _PillNavCell({ + required this.item, + required this.selected, + required this.cs, + required this.animationDuration, + required this.iconSize, + required this.labelGap, + required this.onTap, + required this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final opacityDuration = animationDuration == Duration.zero + ? Duration.zero + : const Duration(milliseconds: 200); + return GestureDetector( + onTap: onTap, + onLongPressStart: onLongPress == null + ? null + : (d) => onLongPress!(d.globalPosition), + behavior: HitTestBehavior.opaque, + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + item.icon, + color: selected ? cs.onPrimary : cs.onSurface, + size: iconSize, + fill: 1, + ), + AnimatedContainer( + duration: animationDuration, + curve: Curves.easeOutCubic, + width: selected ? null : 0, + child: AnimatedOpacity( + duration: opacityDuration, + opacity: selected ? 1.0 : 0.0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: labelGap), + Text( + item.label, + style: TextStyle( + color: cs.onPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9d4e8db..9bf17e2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -601,10 +601,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -749,6 +749,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + photo_manager: + dependency: "direct main" + description: + name: photo_manager + sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2 + url: "https://pub.dev" + source: hosted + version: "3.9.0" platform: dependency: transitive description: @@ -982,10 +990,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ecc0898..3b6d708 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 file_picker: ^8.0.0 + photo_manager: ^3.0.0 image: ^4.3.0 sqflite: ^2.4.2 sqflite_common_ffi: ^2.4.0+2 From 628c809fd2179f460662a149220845a286b183ea Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 7 Jun 2026 14:06:35 +0700 Subject: [PATCH 193/323] =?UTF-8?q?=D0=93=D0=9E=D0=92=D0=9D=D0=9E=D0=A7?= =?UTF-8?q?=D0=98=D0=A1=D0=A2,=20=D0=93=D0=9E=D0=92=D0=9D=D0=9E=D0=A7?= =?UTF-8?q?=D0=98=D0=A1=D0=A2=20=D0=93=D0=9E=D0=92=D0=9D=D0=9E=D0=A7=D0=98?= =?UTF-8?q?=D0=A1=D0=A2.=20=D0=9E=D0=A5=20=D0=93=D0=9E=D0=92=D0=9D=D0=90?= =?UTF-8?q?=20=D0=AF=20=D0=9A=D0=9E=D0=9D=D0=95=D0=A7=D0=9D=D0=9E=20=D0=A3?= =?UTF-8?q?=D0=9D=D0=95=D0=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 70 +- lib/core/utils/format.dart | 95 ++ lib/core/utils/image_utils.dart | 3 + lib/frontend/screens/auth/login_screen.dart | 48 +- .../screens/auth/proxy_settings_sheet.dart | 38 +- .../screens/auth/server_settings_sheet.dart | 44 +- lib/frontend/screens/calls/call_screen.dart | 24 +- lib/frontend/screens/calls/calls_tab.dart | 50 +- .../screens/chats/chat_info_screen.dart | 305 +++--- .../screens/chats/chat_list_screen.dart | 11 +- lib/frontend/screens/chats/chat_screen.dart | 943 ++++++++++-------- .../screens/chats/create_group_flow.dart | 79 +- .../contacts/contact_profile_screen.dart | 151 +-- .../screens/contacts/contacts_tab.dart | 69 +- .../screens/profile/cloud_storage_screen.dart | 250 +++-- .../screens/profile/debug_menu_screen.dart | 52 +- .../screens/profile/devices_screen.dart | 43 +- .../screens/profile/edit_profile_screen.dart | 45 +- lib/frontend/screens/profile/info_screen.dart | 82 +- .../screens/profile/notifications_screen.dart | 55 +- .../profile/password_entry_screen.dart | 60 +- .../screens/profile/performance_screen.dart | 43 +- .../screens/profile/security_screen.dart | 92 +- .../screens/profile/settings_tab.dart | 66 +- .../screens/profile/spoof_screen.dart | 32 +- .../widgets/account_switcher_overlay.dart | 41 +- .../widgets/attachment/attachment_sheet.dart | 24 +- lib/frontend/widgets/confirm_dialog.dart | 44 + lib/frontend/widgets/komet_avatar.dart | 58 ++ lib/frontend/widgets/message_bubble.dart | 453 +++++---- lib/frontend/widgets/section_header.dart | 32 + lib/frontend/widgets/sheet_helpers.dart | 30 + 32 files changed, 1827 insertions(+), 1605 deletions(-) create mode 100644 lib/core/utils/format.dart create mode 100644 lib/frontend/widgets/confirm_dialog.dart create mode 100644 lib/frontend/widgets/komet_avatar.dart create mode 100644 lib/frontend/widgets/section_header.dart create mode 100644 lib/frontend/widgets/sheet_helpers.dart diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 6108dcb..e893153 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -5,6 +5,7 @@ import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; import '../../models/attachment.dart'; import 'chats.dart' show ChatsModule; @@ -24,7 +25,8 @@ class ContactCache { static String? get(int id) => _nameCache[id]; static String? getAvatar(int id) => _avatarCache[id]; static Set? getOptions(int id) => _optionsCache[id]; - static bool isOfficial(int id) => _optionsCache[id]?.contains('OFFICIAL') ?? false; + static bool isOfficial(int id) => + _optionsCache[id]?.contains('OFFICIAL') ?? false; static void clear() { _nameCache.clear(); @@ -81,13 +83,13 @@ class FileHistoryEntry { }); Map toJson() => { - 'fileId': fileId, - if (url != null) 'url': url, - if (token != null) 'token': token, - if (filename != null) 'filename': filename, - if (size != null) 'size': size, - 'sentAt': sentAt.millisecondsSinceEpoch, - }; + 'fileId': fileId, + if (url != null) 'url': url, + if (token != null) 'token': token, + if (filename != null) 'filename': filename, + if (size != null) 'size': size, + 'sentAt': sentAt.millisecondsSinceEpoch, + }; static FileHistoryEntry? fromJson(Map j) { final id = j['fileId']; @@ -108,8 +110,9 @@ class FileHistoryCache { static const _prefKey = 'file_history_v1'; static const _maxEntries = 50; - static final ValueNotifier> notifier = - ValueNotifier(const []); + static final ValueNotifier> notifier = ValueNotifier( + const [], + ); static List get history => notifier.value; static bool get isEmpty => notifier.value.isEmpty; @@ -135,7 +138,10 @@ class FileHistoryCache { } static void add(FileHistoryEntry entry) { - final next = [entry, ...notifier.value.where((e) => e.fileId != entry.fileId)]; + final next = [ + entry, + ...notifier.value.where((e) => e.fileId != entry.fileId), + ]; if (next.length > _maxEntries) next.removeRange(_maxEntries, next.length); notifier.value = next; _persist(); @@ -223,15 +229,24 @@ class CachedMessage { return CachedMessage( id: row['id']?.toString() ?? '', - accountId: row['account_id'] is int ? row['account_id'] as int : int.tryParse(row['account_id']?.toString() ?? '') ?? 0, - chatId: row['chat_id'] is int ? row['chat_id'] as int : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0, - senderId: row['sender_id'] is int ? row['sender_id'] as int : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0, + accountId: row['account_id'] is int + ? row['account_id'] as int + : int.tryParse(row['account_id']?.toString() ?? '') ?? 0, + chatId: row['chat_id'] is int + ? row['chat_id'] as int + : int.tryParse(row['chat_id']?.toString() ?? '') ?? 0, + senderId: row['sender_id'] is int + ? row['sender_id'] as int + : int.tryParse(row['sender_id']?.toString() ?? '') ?? 0, text: row['text']?.toString(), - time: row['time'] is int ? row['time'] as int : int.tryParse(row['time']?.toString() ?? '') ?? 0, + time: row['time'] is int + ? row['time'] as int + : int.tryParse(row['time']?.toString() ?? '') ?? 0, status: row['status']?.toString(), payload: payload, attachments: attachments, - isControl: attachments?.any((a) => a.type == AttachmentType.control) ?? false, + isControl: + attachments?.any((a) => a.type == AttachmentType.control) ?? false, ); } @@ -252,8 +267,7 @@ class CachedMessage { if (attaches is List && attaches.isNotEmpty) { attachments = attaches .whereType() - .map((a) => - MessageAttachment.fromMap(Map.from(a))) + .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); } return CachedMessage( @@ -327,7 +341,7 @@ class MessagesModule { if (rows.isNotEmpty) { AppDatabase.saveMessages(rows).catchError((e) { - debugPrint('saveMessages error: $e'); + logger.e('saveMessages error: $e'); }); } @@ -424,7 +438,9 @@ class MessagesModule { final response = await _api.sendRequest(Opcode.msgSend, payload); if (!response.isOk) { final msg = (response.payload is Map) - ? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка отправки') + ? (response.payload['localizedMessage'] ?? + response.payload['message'] ?? + 'Ошибка отправки') : 'Ошибка отправки'; throw Exception(msg.toString()); } @@ -460,7 +476,10 @@ class MessagesModule { if (transcriptionStatus == 1) { final text = data['transcription'] as String? ?? ''; if (text.isEmpty) { - return TranscriptionResult(status: 1, text: 'не удалось распознать текст'); + return TranscriptionResult( + status: 1, + text: 'не удалось распознать текст', + ); } return TranscriptionResult(status: 1, text: text); } @@ -509,7 +528,7 @@ class MessagesModule { if (token != null) {'_type': 'FILE', 'token': token} else - {'_type': 'FILE', 'fileId': fileId} + {'_type': 'FILE', 'fileId': fileId}, ], }, 'notify': notify, @@ -706,7 +725,10 @@ class MessagesModule { final rawOpts = contact['options']; if (rawOpts is List) { - ContactCache.putOptions(contactId, rawOpts.whereType().toSet()); + ContactCache.putOptions( + contactId, + rawOpts.whereType().toSet(), + ); } ChatsModule.applyContactUpdate(contactId); @@ -716,7 +738,7 @@ class MessagesModule { } } } catch (e) { - debugPrint('searchContactById error: $e'); + logger.e('searchContactById error: $e'); } return null; } diff --git a/lib/core/utils/format.dart b/lib/core/utils/format.dart new file mode 100644 index 0000000..0987c72 --- /dev/null +++ b/lib/core/utils/format.dart @@ -0,0 +1,95 @@ +/// Shared formatting helpers (dates, durations, sizes, phone, gender). +library; + +const List kRuMonthsShort = [ + 'янв', + 'фев', + 'мар', + 'апр', + 'мая', + 'июн', + 'июл', + 'авг', + 'сен', + 'окт', + 'ноя', + 'дек', +]; + +String _two(int n) => n.toString().padLeft(2, '0'); + +/// "512 Б" / "1.5 КБ" / "3.2 МБ" / "1.1 ГБ" — Cyrillic units, 1 decimal. +String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes Б'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} ГБ'; +} + +/// "m:ss" (e.g. "3:07"); with [padMinutes] the minutes are zero-padded ("03:07"). +String formatDurationMmSs(Duration d, {bool padMinutes = false}) { + final m = d.inMinutes; + return '${padMinutes ? _two(m) : m}:${_two(d.inSeconds % 60)}'; +} + +/// "m:ss" from a raw seconds count. +String formatSecondsMmSs(int seconds, {bool padMinutes = false}) => + formatDurationMmSs(Duration(seconds: seconds), padMinutes: padMinutes); + +/// "HH:mm". +String formatClock(DateTime dt) => '${_two(dt.hour)}:${_two(dt.minute)}'; + +/// "5 мая 2024". +String formatDateWords(DateTime dt) => + '${dt.day} ${kRuMonthsShort[dt.month - 1]} ${dt.year}'; + +/// "05.04.2024". +String formatDateNumeric(DateTime dt) => + '${_two(dt.day)}.${_two(dt.month)}.${dt.year}'; + +/// "05.04.2024 14:30". +String formatDateTimeNumeric(DateTime dt) => + '${formatDateNumeric(dt)} ${formatClock(dt)}'; + +/// "5 мая 2024, 14:30". +String formatDateTimeWords(DateTime dt) => + '${formatDateWords(dt)}, ${formatClock(dt)}'; + +/// "Был(-а) только что / N мин назад / N ч назад / N дн назад / 5 мая 2024". +String formatLastSeen(int secondsSinceEpoch) { + final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 2) return 'Был(-а) только что'; + if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад'; + if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад'; + if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад'; + return 'Был(-а) ${formatDateWords(dt)}'; +} + +/// "+7 (912) 345-67-89" for RU numbers, "+digits" otherwise. +/// Accepts an int phone or a string; returns null if there is no usable number. +String? formatPhone(dynamic raw) { + String? digits; + if (raw is int && raw > 0) { + digits = raw.toString(); + } else if (raw is String && raw.isNotEmpty && raw != '***') { + digits = raw.replaceAll(RegExp(r'[^0-9]'), ''); + if (digits.isEmpty) return null; + } + if (digits == null) return null; + if (digits.length == 11 && digits.startsWith('7')) { + return '+${digits[0]} (${digits.substring(1, 4)}) ' + '${digits.substring(4, 7)}-${digits.substring(7, 9)}-${digits.substring(9)}'; + } + return '+$digits'; +} + +/// 1 → "Мужской", 2 → "Женский", anything else → null. +String? formatGender(dynamic raw) { + if (raw is! int) return null; + if (raw == 1) return 'Мужской'; + if (raw == 2) return 'Женский'; + return null; +} diff --git a/lib/core/utils/image_utils.dart b/lib/core/utils/image_utils.dart index a7b99a7..413f6f1 100644 --- a/lib/core/utils/image_utils.dart +++ b/lib/core/utils/image_utils.dart @@ -4,6 +4,9 @@ import 'package:image/image.dart' as img; const int _avatarMaxDimension = 1024; const int _avatarTargetBytes = 900 * 1024; +/// Maximum accepted size for a user-picked avatar before compression. +const int kMaxAvatarBytes = 8 * 1024 * 1024; + Future compressAvatar(Uint8List input) => compute(_encodeAvatar, input); Uint8List? _encodeAvatar(Uint8List input) { diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 40fb36b..5b5dd59 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -16,6 +16,7 @@ import '../profile/spoof_screen.dart'; import '../profile/debug_menu_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/adaptive_shell.dart'; +import '../../widgets/sheet_helpers.dart'; import '../../../backend/api.dart'; import '../../../main.dart'; @@ -152,9 +153,7 @@ class _LoginScreenState extends State { showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (sheetContext) { return SafeArea( child: Padding( @@ -188,7 +187,9 @@ class _LoginScreenState extends State { ), onTap: () { Navigator.pop(sheetContext); - KometApp.stateOf(appContext)?.applyLocale(const Locale('ru')); + KometApp.stateOf( + appContext, + )?.applyLocale(const Locale('ru')); }, ), ListTile( @@ -202,7 +203,9 @@ class _LoginScreenState extends State { ), onTap: () { Navigator.pop(sheetContext); - KometApp.stateOf(appContext)?.applyLocale(const Locale('en')); + KometApp.stateOf( + appContext, + )?.applyLocale(const Locale('en')); }, ), ], @@ -220,9 +223,7 @@ class _LoginScreenState extends State { context: context, backgroundColor: cs.surfaceContainerHigh, isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (context) { double progress = _isTOSRead ? 1.0 : 0.0; return StatefulBuilder( @@ -503,13 +504,9 @@ class _LoginScreenState extends State { context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) { - return SafeArea( - child: const ServerSettingsSheet(), - ); + return SafeArea(child: const ServerSettingsSheet()); }, ); } @@ -520,13 +517,9 @@ class _LoginScreenState extends State { context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) { - return SafeArea( - child: const ProxySettingsSheet(), - ); + return SafeArea(child: const ProxySettingsSheet()); }, ); } @@ -537,9 +530,7 @@ class _LoginScreenState extends State { showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (sheetContext) { return SafeArea( child: Padding( @@ -614,9 +605,7 @@ class _LoginScreenState extends State { showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (context) { return SafeArea( child: Padding( @@ -725,7 +714,8 @@ class _LoginScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: () => _showSecurityOptions(context), + onPressed: () => + _showSecurityOptions(context), icon: Icon( Symbols.admin_panel_settings, color: cs.onSurfaceVariant, @@ -840,7 +830,9 @@ class _LoginScreenState extends State { fontWeight: FontWeight.w400, ), decoration: InputDecoration( - hintText: _phoneMaskHint(_selectedCountry), + hintText: _phoneMaskHint( + _selectedCountry, + ), hintStyle: TextStyle( color: cs.outline, fontSize: 15, diff --git a/lib/frontend/screens/auth/proxy_settings_sheet.dart b/lib/frontend/screens/auth/proxy_settings_sheet.dart index 8553ab1..2c20811 100644 --- a/lib/frontend/screens/auth/proxy_settings_sheet.dart +++ b/lib/frontend/screens/auth/proxy_settings_sheet.dart @@ -7,6 +7,7 @@ import 'package:komet/l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; class ProxySettingsSheet extends StatefulWidget { const ProxySettingsSheet({super.key}); @@ -57,13 +58,15 @@ class _ProxySettingsSheetState extends State { try { final username = _usernameController.text.trim(); final password = _passwordController.text.trim(); - await ProxyConfig.save(ProxySettings( - type: _selectedType, - host: host, - port: port, - username: username.isNotEmpty ? username : null, - password: password.isNotEmpty ? password : null, - )); + await ProxyConfig.save( + ProxySettings( + type: _selectedType, + host: host, + port: port, + username: username.isNotEmpty ? username : null, + password: password.isNotEmpty ? password : null, + ), + ); await api.disconnect(); await api.connect(); if (!mounted) return; @@ -120,16 +123,8 @@ class _ProxySettingsSheetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(2), - ), - ), + const Center( + child: SheetGrabber(margin: EdgeInsets.only(bottom: 16)), ), Text( l10n.proxySettingsTitle, @@ -197,9 +192,7 @@ class _ProxySettingsSheetState extends State { const SizedBox(height: 16), FilledButton( onPressed: _busy ? null : () => _apply(l10n), - child: Text( - isActive ? l10n.proxyApply : l10n.proxyDisable, - ), + child: Text(isActive ? l10n.proxyApply : l10n.proxyDisable), ), ], ), @@ -278,10 +271,7 @@ class _ProxySettingsSheetState extends State { inputFormatters: inputFormatters, enabled: !_busy, obscureText: obscureText, - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 15, - ), + style: GoogleFonts.inter(color: cs.onSurface, fontSize: 15), decoration: InputDecoration( hintText: hintText, hintStyle: GoogleFonts.inter( diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart index e458642..f071f3b 100644 --- a/lib/frontend/screens/auth/server_settings_sheet.dart +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; class ServerSettingsSheet extends StatefulWidget { const ServerSettingsSheet({super.key}); @@ -57,10 +58,13 @@ class _ServerSettingsSheetState extends State { await api.disconnect(); unawaited(api.connect()); final online = await api.stateStream - .firstWhere((s) => - s == SessionState.online || s == SessionState.disconnected) - .timeout(const Duration(seconds: 15), - onTimeout: () => SessionState.disconnected); + .firstWhere( + (s) => s == SessionState.online || s == SessionState.disconnected, + ) + .timeout( + const Duration(seconds: 15), + onTimeout: () => SessionState.disconnected, + ); if (!mounted) return; if (online == SessionState.online) { showCustomNotification(context, l10n.serverSettingsSaved); @@ -83,10 +87,13 @@ class _ServerSettingsSheetState extends State { await api.disconnect(); api.connect(); final online = await api.stateStream - .firstWhere((s) => - s == SessionState.online || s == SessionState.disconnected) - .timeout(const Duration(seconds: 15), - onTimeout: () => SessionState.disconnected); + .firstWhere( + (s) => s == SessionState.online || s == SessionState.disconnected, + ) + .timeout( + const Duration(seconds: 15), + onTimeout: () => SessionState.disconnected, + ); if (!mounted) return; if (online == SessionState.online) { showCustomNotification(context, l10n.serverSettingsSaved); @@ -119,16 +126,8 @@ class _ServerSettingsSheetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(2), - ), - ), + const Center( + child: SheetGrabber(margin: EdgeInsets.only(bottom: 16)), ), Text( l10n.serverSettingsTitle, @@ -153,9 +152,7 @@ class _ServerSettingsSheetState extends State { hintText: '${ServerConfig.defaultPort}', cs: cs, keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], + inputFormatters: [FilteringTextInputFormatter.digitsOnly], ), const SizedBox(height: 24), FilledButton( @@ -199,10 +196,7 @@ class _ServerSettingsSheetState extends State { keyboardType: keyboardType, inputFormatters: inputFormatters, enabled: !_busy, - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 15, - ), + style: GoogleFonts.inter(color: cs.onSurface, fontSize: 15), decoration: InputDecoration( hintText: hintText, hintStyle: GoogleFonts.inter( diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 4ca0beb..43f0ec1 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -4,6 +4,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/utils/format.dart'; + enum CallScreenState { incoming, outgoing, active } class CallScreen extends StatefulWidget { @@ -68,12 +70,6 @@ class _CallScreenState extends State }); } - String get _timerText { - final m = (_seconds ~/ 60).toString().padLeft(2, '0'); - final s = (_seconds % 60).toString().padLeft(2, '0'); - return '$m:$s'; - } - void _accept() { setState(() { _state = CallScreenState.active; @@ -127,13 +123,8 @@ class _CallScreenState extends State return AnimatedBuilder( animation: _pulseAnimation, builder: (context, child) { - final scale = (isRinging || isOutgoing) - ? _pulseAnimation.value - : 1.0; - return Transform.scale( - scale: scale, - child: child, - ); + final scale = (isRinging || isOutgoing) ? _pulseAnimation.value : 1.0; + return Transform.scale(scale: scale, child: child); }, child: Container( width: size, @@ -204,7 +195,7 @@ class _CallScreenState extends State case CallScreenState.outgoing: text = 'Вызов...'; case CallScreenState.active: - text = _timerText; + text = formatSecondsMmSs(_seconds, padMinutes: true); } return Text( text, @@ -322,10 +313,7 @@ class _ActionButton extends StatelessWidget { Container( width: 64, height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: color, - ), + decoration: BoxDecoration(shape: BoxShape.circle, color: color), alignment: Alignment.center, child: Icon(icon, color: Colors.white, size: 28, fill: 1), ), diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index e5f920b..d278eb9 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -1,9 +1,10 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show api; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/format.dart'; import '../../../backend/modules/calls.dart'; +import '../../widgets/komet_avatar.dart'; class CallsTab extends StatefulWidget { const CallsTab({super.key}); @@ -81,36 +82,7 @@ class _CallsTabState extends State { String _formatDate(int timestamp) { if (timestamp == 0) return ''; final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); - final months = [ - 'янв.', - 'фев.', - 'мар.', - 'апр.', - 'мая', - 'июн.', - 'июл.', - 'авг.', - 'сен.', - 'окт.', - 'ноя.', - 'дек.', - ]; - return '${dt.day} ${months[dt.month - 1]}'; - } - - Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { - return Container( - color: cs.primaryContainer, - alignment: Alignment.center, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ); + return '${dt.day} ${kRuMonthsShort[dt.month - 1]}'; } Widget _buildCallItem( @@ -165,18 +137,10 @@ class _CallsTabState extends State { width: 1, ), ), - child: ClipOval( - child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: call.avatarUrl!, - fit: BoxFit.cover, - memCacheWidth: 144, - memCacheHeight: 144, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (context, url, error) => - _buildPlaceholderAvatar(cs, call.name), - ) - : _buildPlaceholderAvatar(cs, call.name), + child: KometAvatar( + name: call.name, + imageUrl: call.avatarUrl, + size: 48, ), ), const SizedBox(width: 16), diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 8965c7a..360a519 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -5,6 +5,8 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/format.dart'; +import '../../widgets/komet_avatar.dart'; class _MemberInfo { final int id; @@ -223,7 +225,12 @@ class _ChatInfoScreenState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(height: 4), - _buildAvatar(cs), + KometAvatar( + name: widget.name, + imageUrl: widget.imageUrl, + size: 96, + fontSize: 36, + ), const SizedBox(height: 14), Text( widget.name, @@ -253,39 +260,6 @@ class _ChatInfoScreenState extends State { ); } - // ─── AVATAR ────────────────────────────────────────────────────────────── - - Widget _buildAvatar(ColorScheme cs) { - return Container( - width: 96, - height: 96, - decoration: BoxDecoration( - shape: BoxShape.circle, color: cs.primaryContainer), - child: widget.imageUrl.isNotEmpty - ? ClipOval( - child: CachedNetworkImage( - imageUrl: widget.imageUrl, - fit: BoxFit.cover, - memCacheWidth: 360, - memCacheHeight: 360, - errorWidget: (context, error, stack) => _avatarLetters(cs), - ), - ) - : _avatarLetters(cs), - ); - } - - Widget _avatarLetters(ColorScheme cs) => Center( - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 36, - fontWeight: FontWeight.bold, - ), - ), - ); - // ─── SUBTITLE ──────────────────────────────────────────────────────────── String _subtitle() { @@ -392,10 +366,13 @@ class _ChatInfoScreenState extends State { } } else { final phone = _contactData?['phone']; - final phoneInt = - phone is int ? phone : int.tryParse(phone?.toString() ?? ''); + final phoneInt = phone is int + ? phone + : int.tryParse(phone?.toString() ?? ''); if (phoneInt != null && phoneInt > 0) { - items.add(_simpleInfoCard(cs, 'Номер телефона', _formatPhone(phoneInt))); + items.add( + _simpleInfoCard(cs, 'Номер телефона', formatPhone(phoneInt)!), + ); } } } else if (widget.chatType == 'CHANNEL') { @@ -417,8 +394,12 @@ class _ChatInfoScreenState extends State { ); } - Widget _simpleInfoCard(ColorScheme cs, String label, String value, - {bool isLink = false}) { + Widget _simpleInfoCard( + ColorScheme cs, + String label, + String value, { + bool isLink = false, + }) { return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), @@ -429,8 +410,10 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), const SizedBox(height: 4), Text( value, @@ -458,19 +441,27 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Ссылка-приглашение', - style: - TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + 'Ссылка-приглашение', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), const SizedBox(height: 4), - Text(link, - style: const TextStyle( - color: Color(0xFF007AFF), fontSize: 15)), + Text( + link, + style: const TextStyle( + color: Color(0xFF007AFF), + fontSize: 15, + ), + ), ], ), ), IconButton( - icon: const Icon(Icons.qr_code_2, - color: Color(0xFF007AFF), size: 22), + icon: const Icon( + Icons.qr_code_2, + color: Color(0xFF007AFF), + size: 22, + ), onPressed: () {}, ), ], @@ -492,16 +483,16 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Описание', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + 'Описание', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), const SizedBox(height: 4), Text( desc, - style: - TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4), + style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4), maxLines: (_descExpanded || !isLong) ? null : collapsedLines, - overflow: - (_descExpanded || !isLong) ? null : TextOverflow.ellipsis, + overflow: (_descExpanded || !isLong) ? null : TextOverflow.ellipsis, ), if (isLong) ...[ const SizedBox(height: 6), @@ -509,8 +500,7 @@ class _ChatInfoScreenState extends State { onTap: () => setState(() => _descExpanded = !_descExpanded), child: Text( _descExpanded ? 'Свернуть' : 'Ещё', - style: const TextStyle( - color: Color(0xFF007AFF), fontSize: 13), + style: const TextStyle(color: Color(0xFF007AFF), fontSize: 13), ), ), ], @@ -598,10 +588,7 @@ class _ChatInfoScreenState extends State { if (_selectedTab.isEmpty) return const SizedBox.shrink(); return AnimatedSwitcher( duration: const Duration(milliseconds: 180), - child: KeyedSubtree( - key: ValueKey(_selectedTab), - child: _tabBody(cs), - ), + child: KeyedSubtree(key: ValueKey(_selectedTab), child: _tabBody(cs)), ); } @@ -632,11 +619,16 @@ class _ChatInfoScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, - color: cs.onSurfaceVariant.withValues(alpha: 0.35), size: 48), + Icon( + icon, + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + size: 48, + ), const SizedBox(height: 12), - Text(label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15)), + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), ], ), ); @@ -648,7 +640,8 @@ class _ChatInfoScreenState extends State { final items = []; if (widget.chatType == 'DIALOG' && !_isBot) { - final bio = (_contactData?['description'] as String?) ?? + final bio = + (_contactData?['description'] as String?) ?? (_contactData?['about'] as String?); if (bio != null && bio.isNotEmpty) { items @@ -696,14 +689,19 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), const SizedBox(height: 4), - Text(value, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500)), + Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), ], ), ); @@ -727,7 +725,11 @@ class _ChatInfoScreenState extends State { } Widget _memberAction( - ColorScheme cs, IconData icon, String label, VoidCallback onTap) { + ColorScheme cs, + IconData icon, + String label, + VoidCallback onTap, + ) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(14), @@ -737,8 +739,7 @@ class _ChatInfoScreenState extends State { children: [ Icon(icon, color: const Color(0xFF007AFF), size: 26), const SizedBox(width: 14), - Text(label, - style: TextStyle(color: cs.onSurface, fontSize: 16)), + Text(label, style: TextStyle(color: cs.onSurface, fontSize: 16)), ], ), ), @@ -746,11 +747,11 @@ class _ChatInfoScreenState extends State { } Widget _listDivider(ColorScheme cs) => Divider( - height: 1, - indent: 56, - endIndent: 0, - color: cs.outlineVariant.withValues(alpha: 0.3), - ); + height: 1, + indent: 56, + endIndent: 0, + color: cs.outlineVariant.withValues(alpha: 0.3), + ); Widget _memberTile(ColorScheme cs, _MemberInfo member) { final name = @@ -768,8 +769,9 @@ class _ChatInfoScreenState extends State { sublabel = 'Был(-а) недавно'; } - final String? roleLabel = - member.isOwner ? 'владелец' : (member.isAdmin ? 'Адмін' : null); + final String? roleLabel = member.isOwner + ? 'владелец' + : (member.isAdmin ? 'Адмін' : null); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), @@ -778,7 +780,11 @@ class _ChatInfoScreenState extends State { (avatar != null && avatar.isNotEmpty) ? CircleAvatar( radius: 22, - backgroundImage: CachedNetworkImageProvider(avatar, maxWidth: 144, maxHeight: 144), + backgroundImage: CachedNetworkImageProvider( + avatar, + maxWidth: 144, + maxHeight: 144, + ), backgroundColor: cs.primaryContainer, ) : CircleAvatar( @@ -787,7 +793,9 @@ class _ChatInfoScreenState extends State { child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle( - color: cs.onPrimaryContainer, fontSize: 16), + color: cs.onPrimaryContainer, + fontSize: 16, + ), ), ), const SizedBox(width: 14), @@ -795,21 +803,26 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(name, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w500)), - Text(sublabel, - style: TextStyle( - color: cs.onSurfaceVariant, fontSize: 13)), + Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + Text( + sublabel, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), ], ), ), if (roleLabel != null) - Text(roleLabel, - style: - TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + roleLabel, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), ], ), ); @@ -821,8 +834,10 @@ class _ChatInfoScreenState extends State { final rows = <({String label, String value})>[]; final chat = _chatData; if (chat == null) { - return Text('Нет данных', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); + return Text( + 'Нет данных', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ); } void add(String label, dynamic val, {bool tsFormat = false}) { @@ -830,7 +845,7 @@ class _ChatInfoScreenState extends State { if (val is bool && !val) return; String str; if (tsFormat && val is int && val > 1) { - str = _formatTs(val); + str = formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(val)); } else if (val is bool) { str = 'да'; } else { @@ -887,8 +902,10 @@ class _ChatInfoScreenState extends State { } if (rows.isEmpty) { - return Text('Нет данных', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); + return Text( + 'Нет данных', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ); } final extraRows = _buildExtraContactRows(); @@ -908,18 +925,21 @@ class _ChatInfoScreenState extends State { rows[i].value, trailing: _trailingFor(rows[i].label, cs), ), - if (i < rows.length - 1 || (_extraContactExpanded && extraRows.isNotEmpty)) + if (i < rows.length - 1 || + (_extraContactExpanded && extraRows.isNotEmpty)) Divider( - height: 10, - color: cs.outlineVariant.withValues(alpha: 0.25)), + height: 10, + color: cs.outlineVariant.withValues(alpha: 0.25), + ), ], if (_extraContactExpanded) for (int i = 0; i < extraRows.length; i++) ...[ _infoRow(cs, extraRows[i].label, extraRows[i].value), if (i < extraRows.length - 1) Divider( - height: 10, - color: cs.outlineVariant.withValues(alpha: 0.25)), + height: 10, + color: cs.outlineVariant.withValues(alpha: 0.25), + ), ], ], ), @@ -932,11 +952,17 @@ class _ChatInfoScreenState extends State { final rows = <({String label, String value})>[]; final reg = c['registrationTime']; if (reg is int && reg > 0) { - rows.add((label: 'Регистрация', value: _formatTs(reg))); + rows.add(( + label: 'Регистрация', + value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(reg)), + )); } final upd = c['updateTime']; if (upd is int && upd > 0) { - rows.add((label: 'Обновлён', value: _formatTs(upd))); + rows.add(( + label: 'Обновлён', + value: formatDateTimeNumeric(DateTime.fromMillisecondsSinceEpoch(upd)), + )); } final country = c['country']; if (country is String && country.isNotEmpty) { @@ -944,7 +970,7 @@ class _ChatInfoScreenState extends State { } final gender = c['gender']; if (gender is int) { - final g = gender == 1 ? 'Мужской' : (gender == 2 ? 'Женский' : null); + final g = formatGender(gender); if (g != null) rows.add((label: 'Пол', value: g)); } final phone = c['phone']; @@ -981,11 +1007,17 @@ class _ChatInfoScreenState extends State { ), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - onPressed: () => setState(() => _extraContactExpanded = !_extraContactExpanded), + onPressed: () => + setState(() => _extraContactExpanded = !_extraContactExpanded), ); } - Widget _infoRow(ColorScheme cs, String label, String value, {Widget? trailing}) { + Widget _infoRow( + ColorScheme cs, + String label, + String value, { + Widget? trailing, + }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( @@ -995,13 +1027,18 @@ class _ChatInfoScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10)), - Text(value, - style: TextStyle( - color: cs.onSurface, - fontSize: 12, - fontWeight: FontWeight.w500)), + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), + ), + Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), ], ), ), @@ -1015,13 +1052,13 @@ class _ChatInfoScreenState extends State { Widget _buildShimmer(ColorScheme cs) { Widget block(double w, double h, {double r = 8}) => Container( - width: w, - height: h, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(r), - ), - ); + width: w, + height: h, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(r), + ), + ); return ListView( padding: const EdgeInsets.fromLTRB(16, 60, 16, 0), @@ -1044,7 +1081,8 @@ class _ChatInfoScreenState extends State { // ─── HELPERS ───────────────────────────────────────────────────────────── String _formatLastSeen(int secondsSinceEpoch) { - final diff = DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000; + final diff = + DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000; if (diff < 60000) return 'только что'; if (diff < 3600000) return '${diff ~/ 60000} мин назад'; if (diff < 86400000) return '${diff ~/ 3600000} ч назад'; @@ -1052,21 +1090,6 @@ class _ChatInfoScreenState extends State { return 'давно'; } - String _formatPhone(int phone) { - final s = phone.toString(); - if (s.length == 11 && s.startsWith('7')) { - return '+7 ${s.substring(1, 4)} ${s.substring(4, 7)}-' - '${s.substring(7, 9)}-${s.substring(9, 11)}'; - } - return '+$s'; - } - - String _formatTs(int ts) { - final dt = DateTime.fromMillisecondsSinceEpoch(ts); - return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; - } - String _pluralCount(int n, String one, String few, String many) { final mod100 = n % 100; final mod10 = n % 10; diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 659fb9a..c5117cd 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -10,8 +10,10 @@ import 'chat_screen.dart'; import 'create_group_flow.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; import '../../widgets/swipe_route.dart'; import '../../widgets/sliding_pill_nav.dart'; +import '../../../core/utils/format.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; @@ -342,9 +344,7 @@ class _ChatListScreenState extends State return showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (ctx) { return SafeArea( child: Padding( @@ -832,10 +832,7 @@ class _ChatListScreenState extends State String _formatTime(int? timestamp) { if (timestamp == null || timestamp == 0) return ''; - final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); - final h = dt.hour.toString().padLeft(2, '0'); - final m = dt.minute.toString().padLeft(2, '0'); - return '$h:$m'; + return formatClock(DateTime.fromMillisecondsSinceEpoch(timestamp)); } Widget _buildChatShimmer() { diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index c3fa1f5..55cbdcc 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -10,6 +10,8 @@ import 'package:flutter/services.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/file_uploader.dart'; import 'package:komet/backend/modules/upload_notification_service.dart'; +import 'package:komet/core/utils/format.dart'; +import 'package:komet/core/utils/logger.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -37,11 +39,7 @@ class _UploadStatus { final int sent; final int total; - const _UploadStatus({ - this.active = false, - this.sent = 0, - this.total = 0, - }); + const _UploadStatus({this.active = false, this.sent = 0, this.total = 0}); bool get awaitingResponse => active && total > 0 && sent >= total; double? get progressValue => @@ -82,19 +80,21 @@ class ChatScreen extends StatefulWidget { State createState() => _ChatScreenState(); } -class _ChatScreenState extends State - with TickerProviderStateMixin { +class _ChatScreenState extends State with TickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final GlobalKey _listKey = GlobalKey(); final ValueNotifier _hasText = ValueNotifier(false); bool _isLoading = true; final ValueNotifier _showAttachmentPanel = ValueNotifier(false); - final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus()); + final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier( + const _UploadStatus(), + ); StreamSubscription? _uploadSub; StreamSubscription? _pushSub; StreamSubscription? _messageEventSub; - final Map?>> _reactionNotifiers = {}; + final Map?>> _reactionNotifiers = + {}; ValueNotifier?> _reactionNotifierFor(CachedMessage m) { final existing = _reactionNotifiers[m.id]; @@ -109,11 +109,14 @@ class _ChatScreenState extends State void _pruneReactionNotifiers() { final liveIds = _messages.map((m) => m.id).toSet(); - final dead = _reactionNotifiers.keys.where((id) => !liveIds.contains(id)).toList(); + final dead = _reactionNotifiers.keys + .where((id) => !liveIds.contains(id)) + .toList(); for (final id in dead) { _reactionNotifiers.remove(id)?.dispose(); } } + final Set _typingUserIds = {}; final Map _typingTimers = {}; int _otherStatus = 0; @@ -132,7 +135,8 @@ class _ChatScreenState extends State int _tempIdCounter = 0; late final AnimationController _attachAnim; - String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; + String _nextTempId() => + 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; late AnimationController _shimmerController; Timer? _shimmerStartTimer; bool _historyKickedOff = false; @@ -150,7 +154,7 @@ class _ChatScreenState extends State late final CurvedAnimation _floatingDateCurved; final Map _separatorKeys = {}; String? _lastSentId; - + @override void initState() { super.initState(); @@ -167,9 +171,9 @@ class _ChatScreenState extends State ); _showAttachmentPanel.addListener(_onAttachPanelToggle); _pushSub = api.pushStream - .where((p) => - p.opcode == Opcode.notifMark || - p.opcode == Opcode.notifTyping) + .where( + (p) => p.opcode == Opcode.notifMark || p.opcode == Opcode.notifTyping, + ) .listen(_onIncomingPush); _messageEventSub = ChatsModule.messageEvents .where((e) => e.chatId == widget.chatId) @@ -206,15 +210,17 @@ class _ChatScreenState extends State if (!mounted) return; _myId = p?.id ?? 0; - ChatsModule.getChat(_myId, widget.chatId).then((value) { - if (mounted && value.isNotEmpty) { - setState(() { - chat = value.first; - }); - _recomputeHeaderStatus(); - _syncOtherReadTime(); - } - }).catchError((_) {}); + ChatsModule.getChat(_myId, widget.chatId) + .then((value) { + if (mounted && value.isNotEmpty) { + setState(() { + chat = value.first; + }); + _recomputeHeaderStatus(); + _syncOtherReadTime(); + } + }) + .catchError((_) {}); final firstRows = await AppDatabase.loadMessages( _myId, @@ -254,6 +260,7 @@ class _ChatScreenState extends State if (!mounted) return; _kickoffHistory(); } + anim.addStatusListener(onStatus); safety = Timer(const Duration(milliseconds: 400), () { anim.removeStatusListener(onStatus); @@ -322,10 +329,12 @@ class _ChatScreenState extends State if (mounted) { _applyMergedMessages(updatedRows, markLoaded: true); } - unawaited(ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId)); + unawaited( + ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId), + ); _loadForwardedSenderNames(); } catch (e) { - debugPrint('Error fetching history: $e'); + logger.e('Error fetching history: $e'); if (mounted) { setState(() { _isLoading = false; @@ -339,9 +348,7 @@ class _ChatScreenState extends State List> rowsDesc, { bool markLoaded = false, }) { - final byId = { - for (final m in _messages) m.id: m, - }; + final byId = {for (final m in _messages) m.id: m}; final merged = []; for (final row in rowsDesc.reversed) { final fresh = CachedMessage.fromDbRow(row); @@ -635,20 +642,6 @@ class _ChatScreenState extends State } catch (_) {} } - String _formatLastSeen(int secondsSinceEpoch) { - final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); - final diff = DateTime.now().difference(dt); - if (diff.inMinutes < 2) return 'Был(-а) только что'; - if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад'; - if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад'; - if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад'; - const months = [ - 'янв', 'фев', 'мар', 'апр', 'мая', 'июн', - 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек', - ]; - return 'Был(-а) ${dt.day} ${months[dt.month - 1]} ${dt.year}'; - } - void _recomputeHeaderStatus() { _headerStatusNotifier.value = _headerStatus(); } @@ -666,7 +659,7 @@ class _ChatScreenState extends State if (_otherStatus == 1) return 'В сети'; if (_otherStatus == 3) return 'Был(-а) недавно'; final s = _otherSeenTime; - if (s != null && s > 0) return _formatLastSeen(s); + if (s != null && s > 0) return formatLastSeen(s); return ''; } @@ -720,7 +713,6 @@ class _ChatScreenState extends State final now = DateTime.now().millisecondsSinceEpoch; try { - final tempMessage = CachedMessage( id: tempId, accountId: _myId, @@ -747,7 +739,11 @@ class _ChatScreenState extends State _scrollToBottom(); _checkPrankTrigger(tempMessage); - final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); + final actualId = await messagesModule.sendMessage( + _myId, + widget.chatId, + text, + ); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { @@ -902,26 +898,34 @@ class _ChatScreenState extends State for (int i = 0; i < _messages.length; i++) { final msg = _messages[i]; final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time); - final dayMillis = DateTime(msgDate.year, msgDate.month, msgDate.day) - .millisecondsSinceEpoch; + final dayMillis = DateTime( + msgDate.year, + msgDate.month, + msgDate.day, + ).millisecondsSinceEpoch; bool needSeparator = i == 0; if (!needSeparator) { - final prevDate = - DateTime.fromMillisecondsSinceEpoch(_messages[i - 1].time); - final prevDayMillis = - DateTime(prevDate.year, prevDate.month, prevDate.day) - .millisecondsSinceEpoch; + final prevDate = DateTime.fromMillisecondsSinceEpoch( + _messages[i - 1].time, + ); + final prevDayMillis = DateTime( + prevDate.year, + prevDate.month, + prevDate.day, + ).millisecondsSinceEpoch; needSeparator = dayMillis != prevDayMillis; } if (needSeparator) { _separatorKeys.putIfAbsent(dayMillis, () => GlobalKey()); usedDates.add(dayMillis); - items.add(_DateSeparatorItem( - DateTime.fromMillisecondsSinceEpoch(dayMillis), - _separatorKeys[dayMillis]!, - )); + items.add( + _DateSeparatorItem( + DateTime.fromMillisecondsSinceEpoch(dayMillis), + _separatorKeys[dayMillis]!, + ), + ); } items.add(_MessageItem(msg, i)); @@ -992,8 +996,18 @@ class _ChatScreenState extends State if (d == yesterday) return 'Вчера'; const months = [ - 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', - 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', + 'января', + 'февраля', + 'марта', + 'апреля', + 'мая', + 'июня', + 'июля', + 'августа', + 'сентября', + 'октября', + 'ноября', + 'декабря', ]; if (date.year == now.year) { return '${date.day} ${months[date.month - 1]}'; @@ -1001,8 +1015,12 @@ class _ChatScreenState extends State return '${date.day} ${months[date.month - 1]} ${date.year}'; } - Widget _buildDateSeparatorWidget(BuildContext context, DateTime date, - {Key? key, bool floating = false}) { + Widget _buildDateSeparatorWidget( + BuildContext context, + DateTime date, { + Key? key, + bool floating = false, + }) { final cs = Theme.of(context).colorScheme; return Padding( key: key, @@ -1029,8 +1047,9 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { - final theme = - _prankActive ? _prankPinkTheme(Theme.of(context)) : Theme.of(context); + final theme = _prankActive + ? _prankPinkTheme(Theme.of(context)) + : Theme.of(context); final cs = theme.colorScheme; // TODO: Локализация @@ -1040,161 +1059,173 @@ class _ChatScreenState extends State child: RepaintBoundary( key: _prankCaptureKey, child: ValueListenableBuilder( - valueListenable: AppSwipeBackDesktop.current, - builder: (context, desktopSwipe, child) => SwipeToPop( - enabled: widget.embedded && desktopSwipe, - onPop: widget.onClose, - child: child!, - ), - child: Scaffold( - backgroundColor: cs.surface, - appBar: PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight), - child: InkWell( - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (context) => ChatInfoScreen( - chatId: widget.chatId, - name: widget.name, - imageUrl: widget.imageUrl, - chatType: widget.chatType) - ) + valueListenable: AppSwipeBackDesktop.current, + builder: (context, desktopSwipe, child) => SwipeToPop( + enabled: widget.embedded && desktopSwipe, + onPop: widget.onClose, + child: child!, ), - child: AppBar( - backgroundColor: cs.surfaceContainerHigh, - foregroundColor: cs.onSurface, - elevation: 0, - surfaceTintColor: Colors.transparent, - iconTheme: IconThemeData(color: cs.onSurface), - leading: IconButton( - icon: Icon( - widget.embedded ? Symbols.close : Symbols.arrow_back, - weight: 400, - ), - onPressed: () { - if (widget.embedded) { - widget.onClose?.call(); - } else { - Navigator.pop(context); - } - }, - ), - titleSpacing: 0, - title: Row( - children: [ - if (widget.imageUrl.isNotEmpty) - CircleAvatar( - radius: 18, - backgroundImage: CachedNetworkImageProvider(widget.imageUrl, maxWidth: 144, maxHeight: 144), - ) - else - CircleAvatar( - radius: 18, - backgroundColor: cs.primaryContainer, - child: Text( - widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), + child: Scaffold( + backgroundColor: cs.surface, + appBar: PreferredSize( + preferredSize: Size.fromHeight(kToolbarHeight), + child: InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatInfoScreen( + chatId: widget.chatId, + name: widget.name, + imageUrl: widget.imageUrl, + chatType: widget.chatType, ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + child: AppBar( + backgroundColor: cs.surfaceContainerHigh, + foregroundColor: cs.onSurface, + elevation: 0, + surfaceTintColor: Colors.transparent, + iconTheme: IconThemeData(color: cs.onSurface), + leading: IconButton( + icon: Icon( + widget.embedded ? Symbols.close : Symbols.arrow_back, + weight: 400, + ), + onPressed: () { + if (widget.embedded) { + widget.onClose?.call(); + } else { + Navigator.pop(context); + } + }, + ), + titleSpacing: 0, + title: Row( children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - widget.name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + if (widget.imageUrl.isNotEmpty) + CircleAvatar( + radius: 18, + backgroundImage: CachedNetworkImageProvider( + widget.imageUrl, + maxWidth: 144, + maxHeight: 144, + ), + ) + else + CircleAvatar( + radius: 18, + backgroundColor: cs.primaryContainer, + child: Text( + widget.name.isNotEmpty + ? widget.name[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 12, ), ), - if (chat?.isOfficial ?? false) ...[ - const SizedBox(width: 4), - Icon( - Symbols.verified, - color: cs.primary, - size: 16, - weight: 600, - fill: 1, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (chat?.isOfficial ?? false) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], + ], + ), + ValueListenableBuilder( + valueListenable: _headerStatusNotifier, + builder: (context, status, _) => Text( + status, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), ), ], - ], - ), - ValueListenableBuilder( - valueListenable: _headerStatusNotifier, - builder: (context, status, _) => Text( - status, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.w400, - ), ), ), ], ), + actions: [ + IconButton( + icon: const Icon(Symbols.call, weight: 400), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Symbols.more_vert, weight: 400), + onPressed: () {}, + ), + ], ), + ), + ), + body: Column( + children: [ + Expanded( + child: _isLoading && _messages.isEmpty + ? _buildShimmerLoading() + : _buildMessagesList(), + ), + AnimatedBuilder( + animation: _attachAnim, + builder: (context, _) { + if (_attachAnim.value == 0) return const SizedBox.shrink(); + final curve = _attachAnim.status == AnimationStatus.reverse + ? Curves.easeIn + : Curves.easeOut; + final t = curve.transform(_attachAnim.value); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: t, + child: Opacity( + opacity: t, + child: AttachmentPanel( + onClose: () => _showAttachmentPanel.value = false, + onPickFile: _pickAndUploadFile, + onSendById: _sendFileById, + ), + ), + ), + ), + ); + }, + ), + _buildInputArea(context), ], ), - actions: [ - IconButton( - icon: const Icon(Symbols.call, weight: 400), - onPressed: () {}, - ), - IconButton( - icon: const Icon(Symbols.more_vert, weight: 400), - onPressed: () {}, - ), - ], ), - )), - body: Column( - children: [ - Expanded( - child: _isLoading && _messages.isEmpty - ? _buildShimmerLoading() - : _buildMessagesList(), - ), - AnimatedBuilder( - animation: _attachAnim, - builder: (context, _) { - if (_attachAnim.value == 0) return const SizedBox.shrink(); - final curve = _attachAnim.status == AnimationStatus.reverse - ? Curves.easeIn - : Curves.easeOut; - final t = curve.transform(_attachAnim.value); - return Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: ClipRect( - child: Align( - alignment: Alignment.bottomCenter, - heightFactor: t, - child: Opacity( - opacity: t, - child: AttachmentPanel( - onClose: () => _showAttachmentPanel.value = false, - onPickFile: _pickAndUploadFile, - onSendById: _sendFileById, - ), - ), - ), - ), - ); - }, - ), - _buildInputArea(context), - ], - ), - ), ), ), ); @@ -1220,68 +1251,72 @@ class _ChatScreenState extends State ValueListenableBuilder( valueListenable: _otherReadTime, builder: (context, _, _) => ValueListenableBuilder( - valueListenable: AppCacheExtent.current, - builder: (context, cacheExtent, _) => ListView.builder( - controller: _scrollController, - reverse: true, - padding: const EdgeInsets.symmetric(vertical: 8), - cacheExtent: cacheExtent, - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[items.length - 1 - index]; + valueListenable: AppCacheExtent.current, + builder: (context, cacheExtent, _) => ListView.builder( + controller: _scrollController, + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 8), + cacheExtent: cacheExtent, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[items.length - 1 - index]; - if (item is _DateSeparatorItem) { - return _buildDateSeparatorWidget(context, item.date, - key: item.key); - } + if (item is _DateSeparatorItem) { + return _buildDateSeparatorWidget( + context, + item.date, + key: item.key, + ); + } - final msgItem = item as _MessageItem; - final message = msgItem.message; - final msgIndex = msgItem.index; - final isMe = message.senderId == _myId; - final prevMessage = - msgIndex > 0 ? _messages[msgIndex - 1] : null; - final nextMessage = msgIndex < _messages.length - 1 - ? _messages[msgIndex + 1] - : null; + final msgItem = item as _MessageItem; + final message = msgItem.message; + final msgIndex = msgItem.index; + final isMe = message.senderId == _myId; + final prevMessage = msgIndex > 0 + ? _messages[msgIndex - 1] + : null; + final nextMessage = msgIndex < _messages.length - 1 + ? _messages[msgIndex + 1] + : null; - final bubble = MessageBubble( - message: message, - isMe: isMe, - myId: _myId, - prevMessage: prevMessage, - nextMessage: nextMessage, - chatType: chat?.type ?? 'CHAT', - overrideStatus: _effectiveStatus(message), - reactionsListenable: _reactionNotifierFor(message), - ); + final bubble = MessageBubble( + message: message, + isMe: isMe, + myId: _myId, + prevMessage: prevMessage, + nextMessage: nextMessage, + chatType: chat?.type ?? 'CHAT', + overrideStatus: _effectiveStatus(message), + reactionsListenable: _reactionNotifierFor(message), + ); - final pressable = _LongPressBubble( - message: message, - isMe: isMe, - child: bubble, - ); + final pressable = _LongPressBubble( + message: message, + isMe: isMe, + child: bubble, + ); - final Widget child = message.id == _lastSentId - ? _SentMessageAnimation( - key: ValueKey('anim_${message.id}'), - onComplete: () { - if (mounted) setState(() => _lastSentId = null); - }, - child: pressable, - ) - : pressable; + final Widget child = message.id == _lastSentId + ? _SentMessageAnimation( + key: ValueKey('anim_${message.id}'), + onComplete: () { + if (mounted) setState(() => _lastSentId = null); + }, + child: pressable, + ) + : pressable; - final builtItem = RepaintBoundary( - key: ValueKey('msg_${message.id}'), - child: child, - ); - return message.id == _prankBubbleId - ? KeyedSubtree(key: _prankBubbleKey, child: builtItem) - : builtItem; - }, - ), - ), + final builtItem = RepaintBoundary( + key: ValueKey('msg_${message.id}'), + child: child, + ); + return message.id == _prankBubbleId + ? KeyedSubtree(key: _prankBubbleKey, child: builtItem) + : builtItem; + }, + ), + ), ), Positioned( top: 8, @@ -1304,7 +1339,11 @@ class _ChatScreenState extends State ), ); }, - child: _buildDateSeparatorWidget(context, date, floating: true), + child: _buildDateSeparatorWidget( + context, + date, + floating: true, + ), ); }, ), @@ -1483,7 +1522,10 @@ class _ChatScreenState extends State final t = _attachAnim.value; return IgnorePointer( ignoring: t > 0.5, - child: Opacity(opacity: (1 - t).clamp(0.0, 1.0), child: child), + child: Opacity( + opacity: (1 - t).clamp(0.0, 1.0), + child: child, + ), ); }, child: Padding( @@ -1491,14 +1533,22 @@ class _ChatScreenState extends State child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400), + Icon( + Symbols.face, + color: mutedIcon, + size: 24, + weight: 400, + ), const SizedBox(width: 12), Expanded( child: Focus( onKeyEvent: (node, event) { if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.enter && - !HardwareKeyboard.instance.isShiftPressed) { + event.logicalKey == + LogicalKeyboardKey.enter && + !HardwareKeyboard + .instance + .isShiftPressed) { if (_hasText.value) _sendMessage(); return KeyEventResult.handled; } @@ -1506,7 +1556,10 @@ class _ChatScreenState extends State }, child: TextField( controller: _messageController, - style: TextStyle(color: cs.onSurface, fontSize: 16), + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + ), maxLines: null, keyboardType: TextInputType.multiline, textAlignVertical: TextAlignVertical.center, @@ -1548,7 +1601,10 @@ class _ChatScreenState extends State final t = _attachAnim.value; return IgnorePointer( ignoring: t < 0.5, - child: Opacity(opacity: t.clamp(0.0, 1.0), child: child), + child: Opacity( + opacity: t.clamp(0.0, 1.0), + child: child, + ), ); }, child: _HistoryStrip( @@ -1598,7 +1654,9 @@ class _ChatScreenState extends State height: 54, alignment: Alignment.center, decoration: BoxDecoration( - color: hasText ? cs.primary : cs.surfaceContainerHighest, + color: hasText + ? cs.primary + : cs.surfaceContainerHighest, shape: BoxShape.circle, ), child: GestureDetector( @@ -1670,12 +1728,14 @@ class _ChatScreenState extends State } Future _sendHistoryFile(FileHistoryEntry entry) async { - final tempId = _addOptimisticFileMessage(FileAttachment( - fileId: entry.fileId, - fileToken: entry.token, - name: entry.filename, - size: entry.size, - )); + final tempId = _addOptimisticFileMessage( + FileAttachment( + fileId: entry.fileId, + fileToken: entry.token, + name: entry.filename, + size: entry.size, + ), + ); _showAttachmentPanel.value = false; try { final ok = await messagesModule.sendFileMessage( @@ -1695,10 +1755,9 @@ class _ChatScreenState extends State final ok = await messagesModule.sendFileMessage(widget.chatId, fileId); if (!mounted) return ok; if (ok) { - FileHistoryCache.add(FileHistoryEntry( - fileId: fileId, - sentAt: DateTime.now(), - )); + FileHistoryCache.add( + FileHistoryEntry(fileId: fileId, sentAt: DateTime.now()), + ); _updateFileMessageStatus(tempId, 'sent'); _showAttachmentPanel.value = false; } else { @@ -1726,10 +1785,9 @@ class _ChatScreenState extends State _showAttachmentPanel.value = false; _uploadStatus.value = _UploadStatus(active: true, total: file.size); - final tempId = _addOptimisticFileMessage(FileAttachment( - name: file.name, - size: file.size, - )); + final tempId = _addOptimisticFileMessage( + FileAttachment(name: file.name, size: file.size), + ); UploadNotificationService.start(file.name); @@ -1743,83 +1801,94 @@ class _ChatScreenState extends State _uploadSub?.cancel(); _uploadSub = fileUploader .upload( - chatId: widget.chatId, - file: File(file.path!), - filename: file.name, - totalSize: file.size, - ) + chatId: widget.chatId, + file: File(file.path!), + filename: file.name, + totalSize: file.size, + ) .listen( - (event) { - if (!mounted) return; - switch (event) { - case UploadProgress(:final sent, :final total): - _uploadStatus.value = _UploadStatus(active: true, sent: sent, total: total); - final nowMs = DateTime.now().millisecondsSinceEpoch; - final elapsed = nowMs - notifLastMs; - if (elapsed >= 500) { - notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed).round(); - notifLastSent = sent; - notifLastMs = nowMs; + (event) { + if (!mounted) return; + switch (event) { + case UploadProgress(:final sent, :final total): + _uploadStatus.value = _UploadStatus( + active: true, + sent: sent, + total: total, + ); + final nowMs = DateTime.now().millisecondsSinceEpoch; + final elapsed = nowMs - notifLastMs; + if (elapsed >= 500) { + notifSpeedBps = ((sent - notifLastSent) * 1000 / elapsed) + .round(); + notifLastSent = sent; + notifLastMs = nowMs; + } + final percent = total > 0 ? (sent * 100 ~/ total) : 0; + if (percent != notifLastPercent) { + notifLastPercent = percent; + UploadNotificationService.update( + filename: file.name, + progressPercent: percent, + speedBps: notifSpeedBps, + ); + } + case UploadDone(:final fileId, :final token, :final url): + stopNotif(); + FileHistoryCache.add( + FileHistoryEntry( + fileId: fileId, + url: url, + token: token, + filename: file.name, + size: file.size, + sentAt: DateTime.now(), + ), + ); + _updateFileMessageStatus( + tempId, + 'sent', + attachment: FileAttachment( + fileId: fileId, + fileToken: token, + name: file.name, + size: file.size, + ), + ); + case UploadError(:final message): + stopNotif(); + showCustomNotification(context, 'Ошибка: $message'); + _updateFileMessageStatus(tempId, 'error'); } - final percent = total > 0 ? (sent * 100 ~/ total) : 0; - if (percent != notifLastPercent) { - notifLastPercent = percent; - UploadNotificationService.update( - filename: file.name, - progressPercent: percent, - speedBps: notifSpeedBps, - ); - } - case UploadDone(:final fileId, :final token, :final url): + }, + onDone: () { + if (!mounted) return; stopNotif(); - FileHistoryCache.add(FileHistoryEntry( - fileId: fileId, - url: url, - token: token, - filename: file.name, - size: file.size, - sentAt: DateTime.now(), - )); - _updateFileMessageStatus( - tempId, - 'sent', - attachment: FileAttachment( - fileId: fileId, - fileToken: token, - name: file.name, - size: file.size, + final inFlight = _messages.firstWhere( + (m) => m.id == tempId, + orElse: () => CachedMessage( + id: '', + accountId: 0, + chatId: 0, + senderId: 0, + time: 0, ), ); - case UploadError(:final message): + if (inFlight.id == tempId && inFlight.status == 'sending') { + _updateFileMessageStatus(tempId, 'error'); + } + _uploadStatus.value = const _UploadStatus(); + _uploadSub = null; + }, + onError: (Object e) { + if (!mounted) return; stopNotif(); - showCustomNotification(context, 'Ошибка: $message'); + showCustomNotification(context, 'Ошибка: $e'); _updateFileMessageStatus(tempId, 'error'); - } - }, - onDone: () { - if (!mounted) return; - stopNotif(); - final inFlight = _messages.firstWhere( - (m) => m.id == tempId, - orElse: () => CachedMessage( - id: '', accountId: 0, chatId: 0, senderId: 0, time: 0, - ), + _uploadStatus.value = const _UploadStatus(); + _uploadSub = null; + }, ); - if (inFlight.id == tempId && inFlight.status == 'sending') { - _updateFileMessageStatus(tempId, 'error'); - } - _uploadStatus.value = const _UploadStatus(); - _uploadSub = null; - }, - onError: (Object e) { - if (!mounted) return; - stopNotif(); - showCustomNotification(context, 'Ошибка: $e'); - _updateFileMessageStatus(tempId, 'error'); - _uploadStatus.value = const _UploadStatus(); - _uploadSub = null; - }, - ); } } @@ -1848,8 +1917,8 @@ class _AttachButton extends StatelessWidget { final iconColor = status.awaitingResponse ? cs.primary : (status.active - ? cs.onSurfaceVariant.withValues(alpha: 0.5) - : mutedIcon); + ? cs.onSurfaceVariant.withValues(alpha: 0.5) + : mutedIcon); final onTap = (isText || status.active) ? null : onOpen; return AnimatedContainer( duration: const Duration(milliseconds: 200), @@ -1933,86 +2002,98 @@ class _HistoryStrip extends StatelessWidget { itemCount: history.length, itemBuilder: (ctx, idx) { final e = history[idx]; - final startInterval = (idx * 0.05).clamp(0.0, 0.45); - return AnimatedBuilder( - animation: anim, - builder: (context, child) { - final raw = ((anim.value - startInterval) / 0.45).clamp(0.0, 1.0); - final v = Curves.easeOutCubic.transform(raw); - return Opacity( - opacity: v, - child: Transform.translate( - offset: Offset(-14 * (1 - v), 0), - child: child, - ), - ); - }, - child: Container( - width: 54, - margin: const EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - color: cs.surfaceContainerLow, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)), - ), - child: Stack(children: [ - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onTapEntry(e), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _iconForFilename(e.filename), - color: cs.onSurfaceVariant, - size: 22, - ), - const SizedBox(height: 2), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 3), - child: Text( - _labelForEntry(e), - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9), - overflow: TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.center, + final startInterval = (idx * 0.05).clamp(0.0, 0.45); + return AnimatedBuilder( + animation: anim, + builder: (context, child) { + final raw = ((anim.value - startInterval) / 0.45).clamp( + 0.0, + 1.0, + ); + final v = Curves.easeOutCubic.transform(raw); + return Opacity( + opacity: v, + child: Transform.translate( + offset: Offset(-14 * (1 - v), 0), + child: child, + ), + ); + }, + child: Container( + width: 54, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTapEntry(e), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _iconForFilename(e.filename), + color: cs.onSurfaceVariant, + size: 22, + ), + const SizedBox(height: 2), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 3, + ), + child: Text( + _labelForEntry(e), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 9, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], ), ), - ], - ), - ), - ), - Positioned( - top: -2, - right: -2, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => FileHistoryCache.remove(e.fileId), - child: Container( - width: 18, - height: 18, - alignment: Alignment.center, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - shape: BoxShape.circle, - border: Border.all( - color: cs.outlineVariant.withValues(alpha: 0.5), - width: 0.5, + ), + Positioned( + top: -2, + right: -2, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => FileHistoryCache.remove(e.fileId), + child: Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Icon( + Symbols.close, + size: 12, + color: cs.onSurfaceVariant, + ), + ), ), ), - child: Icon( - Symbols.close, - size: 12, - color: cs.onSurfaceVariant, - ), - ), + ], ), ), - ]), - ), - ); + ); }, ); }, @@ -2215,10 +2296,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { _controller?.updatePointer(d.globalPosition), onLongPressEnd: (_) => _controller?.commit(), onSecondaryTapDown: _onSecondaryTapDown, - child: RepaintBoundary( - key: _boundaryKey, - child: widget.child, - ), + child: RepaintBoundary(key: _boundaryKey, child: widget.child), ), ); } @@ -2252,9 +2330,10 @@ class _SentMessageAnimationState extends State<_SentMessageAnimation> duration: const Duration(milliseconds: 220), ); _opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut); - _slide = Tween(begin: 16, end: 0).animate( - CurvedAnimation(parent: _ctrl, curve: Curves.easeOut), - ); + _slide = Tween( + begin: 16, + end: 0, + ).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); _ctrl.forward().whenComplete(widget.onComplete); } diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index c35dca9..faef130 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -11,20 +11,17 @@ import '../../../core/storage/token_storage.dart'; import '../../../core/utils/image_utils.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; import '../../widgets/swipe_route.dart'; import 'chat_screen.dart'; -const int _maxAvatarBytes = 8 * 1024 * 1024; - Future showCreateGroupFlow(BuildContext context) async { final cs = Theme.of(context).colorScheme; await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) => const _CreateGroupFlow(), ); } @@ -70,7 +67,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { } final list = await ContactsModule.getContacts(myId); list.removeWhere((c) => c.id == myId); - list.sort((a, b) => _displayName(a).toLowerCase().compareTo(_displayName(b).toLowerCase())); + list.sort( + (a, b) => _displayName( + a, + ).toLowerCase().compareTo(_displayName(b).toLowerCase()), + ); if (!mounted) return; setState(() { _all = list; @@ -102,7 +103,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { if (path == null) return; final file = File(path); final size = await file.length(); - if (size > _maxAvatarBytes) { + if (size > kMaxAvatarBytes) { if (!mounted) return; showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); return; @@ -134,7 +135,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { if (url != null) { final bytes = await compressAvatar(await _avatar!.readAsBytes()); if (bytes == null) { - if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку'); + if (mounted) { + showCustomNotification(context, 'Не удалось обработать аватарку'); + } } else { final token = await fileUploader.uploadImage( Uri.parse(url), @@ -142,7 +145,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { filename: 'avatar.jpg', ); if (token != null) { - await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); + await ChatsModule.setChatPhoto( + api, + chatId: chat.id, + photoToken: token, + ); } else if (mounted) { showCustomNotification(context, 'Не удалось загрузить аватарку'); } @@ -183,7 +190,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { padding: EdgeInsets.only(bottom: viewInsets.bottom), child: SafeArea( child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), switchInCurve: Curves.easeOut, @@ -193,7 +202,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { ? Offset(-0.05, 0) : Offset(0.05, 0); return SlideTransition( - position: Tween(begin: offset, end: Offset.zero).animate(anim), + position: Tween( + begin: offset, + end: Offset.zero, + ).animate(anim), child: FadeTransition(opacity: anim, child: child), ); }, @@ -217,7 +229,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { final query = _search.text.trim().toLowerCase(); final filtered = query.isEmpty ? _all - : _all.where((c) => _displayName(c).toLowerCase().contains(query)).toList(); + : _all + .where((c) => _displayName(c).toLowerCase().contains(query)) + .toList(); return Column( mainAxisSize: MainAxisSize.min, @@ -269,7 +283,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { decoration: InputDecoration( hintText: 'Найти по имени', hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - prefixIcon: Icon(Symbols.search, color: cs.onSurfaceVariant, size: 20), + prefixIcon: Icon( + Symbols.search, + color: cs.onSurfaceVariant, + size: 20, + ), isDense: true, border: InputBorder.none, ), @@ -291,7 +309,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { return InkWell( onTap: () => _toggle(c), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Row( children: [ _Avatar(contact: c, size: 40, cs: cs), @@ -316,7 +337,9 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { Text( _statusText(c), style: TextStyle( - color: cs.onSurfaceVariant.withValues(alpha: 0.8), + color: cs.onSurfaceVariant.withValues( + alpha: 0.8, + ), fontSize: 12, ), maxLines: 1, @@ -333,7 +356,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { color: cs.primary, shape: BoxShape.circle, ), - child: Icon(Symbols.check, color: cs.onPrimary, size: 16), + child: Icon( + Symbols.check, + color: cs.onPrimary, + size: 16, + ), ), ], ), @@ -419,7 +446,11 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { clipBehavior: Clip.antiAlias, child: _avatar != null ? Image.file(_avatar!, fit: BoxFit.cover) - : Icon(Symbols.add_a_photo, color: cs.onSurfaceVariant, size: 20), + : Icon( + Symbols.add_a_photo, + color: cs.onSurfaceVariant, + size: 20, + ), ), ), const SizedBox(width: 12), @@ -431,7 +462,10 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { style: TextStyle(color: cs.onSurface, fontSize: 16), decoration: InputDecoration( hintText: 'Название группы', - hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), border: InputBorder.none, isDense: true, ), @@ -500,7 +534,10 @@ class _Avatar extends StatelessWidget { return Container( width: size, height: size, - decoration: BoxDecoration(color: cs.primaryContainer, shape: BoxShape.circle), + decoration: BoxDecoration( + color: cs.primaryContainer, + shape: BoxShape.circle, + ), alignment: Alignment.center, child: Text( initial, @@ -589,8 +626,8 @@ class _SheetButton extends StatelessWidget { color: filled ? cs.onPrimary : (disabled - ? cs.onSurface.withValues(alpha: 0.4) - : cs.onSurface), + ? cs.onSurface.withValues(alpha: 0.4) + : cs.onSurface), fontSize: 14, fontWeight: FontWeight.w600, ), diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index b433648..6a89c0b 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -1,11 +1,12 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/format.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/komet_avatar.dart'; import '../../widgets/swipe_route.dart'; import '../chats/chat_screen.dart'; @@ -96,64 +97,10 @@ class _ContactProfileScreenState extends State { if (_isBot) return 'Бот'; if (_presenceStatus == 1) return 'В сети'; if (_presenceStatus == 3) return 'Был(-а) недавно'; - if (_seenTime != null && _seenTime! > 0) return _formatLastSeen(_seenTime!); + if (_seenTime != null && _seenTime! > 0) return formatLastSeen(_seenTime!); return ''; } - String _formatLastSeen(int secondsSinceEpoch) { - final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); - final now = DateTime.now(); - final diff = now.difference(dt); - if (diff.inMinutes < 2) return 'Был(-а) только что'; - if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад'; - if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад'; - if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад'; - return 'Был(-а) ${_formatDate(dt)}'; - } - - String _formatDate(DateTime dt) { - const months = [ - 'янв', 'фев', 'мар', 'апр', 'мая', 'июн', - 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек', - ]; - return '${dt.day} ${months[dt.month - 1]} ${dt.year}'; - } - - String _formatDateTime(int msSinceEpoch) { - final dt = DateTime.fromMillisecondsSinceEpoch(msSinceEpoch); - final hh = dt.hour.toString().padLeft(2, '0'); - final mm = dt.minute.toString().padLeft(2, '0'); - return '${_formatDate(dt)}, $hh:$mm'; - } - - String? _formatPhone(dynamic raw) { - String? digits; - if (raw is int && raw > 0) { - digits = raw.toString(); - } else if (raw is String && raw.isNotEmpty && raw != '***') { - digits = raw.replaceAll(RegExp(r'[^0-9]'), ''); - if (digits.isEmpty) return null; - } - if (digits == null) return null; - if (digits.length == 11 && digits.startsWith('7')) { - final p = digits; - return '+${p[0]} (${p.substring(1, 4)}) ${p.substring(4, 7)}-${p.substring(7, 9)}-${p.substring(9)}'; - } - return '+$digits'; - } - - String? _formatGender(dynamic raw) { - if (raw is! int) return null; - switch (raw) { - case 1: - return 'Мужской'; - case 2: - return 'Женский'; - default: - return null; - } - } - Future _openChat() async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return; @@ -204,7 +151,12 @@ class _ContactProfileScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ - _buildAvatar(cs), + KometAvatar( + name: _displayName(), + imageUrl: _avatarUrl(), + size: 96, + fontSize: 36, + ), const SizedBox(height: 14), _buildNameRow(cs), const SizedBox(height: 4), @@ -225,41 +177,6 @@ class _ContactProfileScreenState extends State { ); } - Widget _buildAvatar(ColorScheme cs) { - final url = _avatarUrl(); - return Container( - width: 96, - height: 96, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.primaryContainer, - ), - child: (url != null && url.isNotEmpty) - ? ClipOval( - child: CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - errorWidget: (_, _, _) => _avatarLetters(cs), - ), - ) - : _avatarLetters(cs), - ); - } - - Widget _avatarLetters(ColorScheme cs) { - final name = _displayName(); - return Center( - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 36, - fontWeight: FontWeight.bold, - ), - ), - ); - } - Widget _buildNameRow(ColorScheme cs) { return Row( mainAxisAlignment: MainAxisAlignment.center, @@ -280,12 +197,7 @@ class _ContactProfileScreenState extends State { ), if (_isVerified) ...[ const SizedBox(width: 6), - Icon( - Symbols.verified, - color: cs.primary, - size: 20, - fill: 1, - ), + Icon(Symbols.verified, color: cs.primary, size: 20, fill: 1), ], ], ); @@ -295,8 +207,7 @@ class _ContactProfileScreenState extends State { final actions = <({IconData icon, String label, VoidCallback? onTap})>[ (icon: Symbols.chat_bubble, label: 'Чат', onTap: _openChat), (icon: Symbols.notifications, label: 'Звук', onTap: null), - if (!_isBot) - (icon: Symbols.call, label: 'Звонок', onTap: null), + if (!_isBot) (icon: Symbols.call, label: 'Звонок', onTap: null), ]; return Row( children: [ @@ -336,7 +247,7 @@ class _ContactProfileScreenState extends State { final rows = []; - final phoneStr = _formatPhone(c['phone']); + final phoneStr = formatPhone(c['phone']); if (phoneStr != null) { rows.add(_infoRow(cs, Symbols.phone, 'Телефон', phoneStr)); } @@ -346,24 +257,45 @@ class _ContactProfileScreenState extends State { rows.add(_infoRow(cs, Symbols.public, 'Страна', country)); } - final genderStr = _formatGender(c['gender']); + final genderStr = formatGender(c['gender']); if (genderStr != null) { rows.add(_infoRow(cs, Symbols.wc, 'Пол', genderStr)); } final regTime = c['registrationTime'] as int?; if (regTime != null && regTime > 0) { - rows.add(_infoRow(cs, Symbols.event, 'Регистрация', _formatDateTime(regTime))); + rows.add( + _infoRow( + cs, + Symbols.event, + 'Регистрация', + formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(regTime)), + ), + ); } final updateTime = c['updateTime'] as int?; if (updateTime != null && updateTime > 0) { - rows.add(_infoRow(cs, Symbols.update, 'Обновлён', _formatDateTime(updateTime))); + rows.add( + _infoRow( + cs, + Symbols.update, + 'Обновлён', + formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(updateTime)), + ), + ); } final accountStatus = c['accountStatus']; if (accountStatus is int && accountStatus != 0) { - rows.add(_infoRow(cs, Symbols.account_circle, 'Статус аккаунта', accountStatus.toString())); + rows.add( + _infoRow( + cs, + Symbols.account_circle, + 'Статус аккаунта', + accountStatus.toString(), + ), + ); } final desc = (c['description'] as String?)?.trim(); @@ -383,7 +315,9 @@ class _ContactProfileScreenState extends State { final opts = _options(); if (opts.isNotEmpty) { - rows.add(_infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true)); + rows.add( + _infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true), + ); } rows.add(_infoRow(cs, Symbols.tag, 'ID', widget.contactId.toString())); @@ -401,7 +335,10 @@ class _ContactProfileScreenState extends State { children: [ for (var i = 0; i < rows.length; i++) ...[ if (i > 0) - Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), rows[i], ], ], diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 3e4abdc..595a3d7 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/protocol/opcode_map.dart'; @@ -6,6 +5,8 @@ import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; import '../../../backend/modules/contacts.dart'; import '../../../main.dart'; +import '../../widgets/komet_avatar.dart'; +import '../../widgets/sheet_helpers.dart'; import 'contact_profile_screen.dart'; class ContactsTab extends StatefulWidget { @@ -31,9 +32,7 @@ class _ContactsTabState extends State { context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (_) => const _SearchContactSheet(), ); } @@ -55,21 +54,6 @@ class _ContactsTabState extends State { } } - Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { - return Container( - color: cs.primaryContainer, - alignment: Alignment.center, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ); - } - Widget _buildContactItem( BuildContext context, ColorScheme cs, @@ -109,18 +93,10 @@ class _ContactsTabState extends State { width: 1, ), ), - child: ClipOval( - child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: contact.baseUrl!, - fit: BoxFit.cover, - memCacheWidth: 144, - memCacheHeight: 144, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (context, url, error) => - _buildPlaceholderAvatar(cs, nameToDisplay), - ) - : _buildPlaceholderAvatar(cs, nameToDisplay), + child: KometAvatar( + name: nameToDisplay, + imageUrl: contact.baseUrl, + size: 48, ), ), const SizedBox(width: 16), @@ -366,8 +342,15 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { style: TextStyle(color: cs.onSurface, fontSize: 16), decoration: InputDecoration( hintText: 'Введите ID контакта', - hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), - prefixIcon: Icon(Symbols.tag, color: cs.onSurfaceVariant, size: 20), + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + prefixIcon: Icon( + Symbols.tag, + color: cs.onSurfaceVariant, + size: 20, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), @@ -380,19 +363,29 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { if (_error != null) ...[ const SizedBox(height: 10), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), decoration: BoxDecoration( color: cs.errorContainer.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ - Icon(Symbols.error_outline, size: 18, color: cs.onErrorContainer), + Icon( + Symbols.error_outline, + size: 18, + color: cs.onErrorContainer, + ), const SizedBox(width: 8), Expanded( child: Text( _error!, - style: TextStyle(color: cs.onErrorContainer, fontSize: 13), + style: TextStyle( + color: cs.onErrorContainer, + fontSize: 13, + ), ), ), ], @@ -403,7 +396,9 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { FilledButton( onPressed: _loading ? null : _submit, style: FilledButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), padding: const EdgeInsets.symmetric(vertical: 14), ), child: _loading diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index a08b595..851db05 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -11,8 +11,10 @@ import '../../../backend/modules/chats.dart'; import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/upload_manager.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/format.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; enum _EnvState { loading, notConfigured, ready } @@ -108,7 +110,8 @@ class _CloudStorageScreenState extends State final cachedId = await CloudStorageModule.getCachedEnvGroupId(profile.id); if (cachedId != null) { final rows = await ChatsModule.getChat(profile.id, cachedId); - if (rows.isNotEmpty && CloudStorageModule.isCloudStorageGroup(rows.first)) { + if (rows.isNotEmpty && + CloudStorageModule.isCloudStorageGroup(rows.first)) { if (!mounted) return; setState(() { _envState = _EnvState.ready; @@ -127,7 +130,10 @@ class _CloudStorageScreenState extends State final orphans = CloudStorageModule.findOrphanGroups(chats); if (envGroup == null && orphans.isNotEmpty) { - final repaired = await CloudStorageModule.repairOrphan(api, orphans.first); + final repaired = await CloudStorageModule.repairOrphan( + api, + orphans.first, + ); if (repaired != null) { envGroup = repaired; await CloudStorageModule.cacheEnvGroupId(profile.id, repaired.id); @@ -161,14 +167,23 @@ class _CloudStorageScreenState extends State void _deleteOrLeave(int accountId, CachedChat chat) async { final isAdmin = chat.owner == accountId || chat.admins.contains(accountId); if (isAdmin) { - await ChatsModule.deleteChat(api, chatId: chat.id, lastEventTime: chat.lastEventTime, forAll: true); + await ChatsModule.deleteChat( + api, + chatId: chat.id, + lastEventTime: chat.lastEventTime, + forAll: true, + ); } else { await ChatsModule.leaveChat(api, chatId: chat.id); } } Future _loadFiles(int accountId, int chatId) async { - final files = await CloudStorageModule.fetchFiles(messagesModule, accountId, chatId); + final files = await CloudStorageModule.fetchFiles( + messagesModule, + accountId, + chatId, + ); if (!mounted) return; setState(() => _files = files.reversed.toList()); } @@ -179,8 +194,11 @@ class _CloudStorageScreenState extends State _animateNewCard = true; }); if (_pageController.hasClients) { - _pageController.animateToPage(0, - duration: const Duration(milliseconds: 350), curve: Curves.easeOut); + _pageController.animateToPage( + 0, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOut, + ); } Future.delayed(const Duration(milliseconds: 800), () { if (mounted) setState(() => _animateNewCard = false); @@ -248,7 +266,10 @@ class _CloudStorageScreenState extends State final ok = await messagesModule.sendFileMessage(chatId, id); if (!ok) return false; final newest = await CloudStorageModule.fetchLatestFile( - messagesModule, accountId, chatId, expectedFileId: id, + messagesModule, + accountId, + chatId, + expectedFileId: id, ); if (mounted) { if (newest != null) { @@ -316,23 +337,45 @@ class _CloudStorageScreenState extends State Text( 'Среда для облачного хранилища не настроена', textAlign: TextAlign.center, - style: TextStyle(color: cs.onSurface, fontSize: 17, fontWeight: FontWeight.w600), + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 6), - Text('Начнем? Это быстро.', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)), + Text( + 'Начнем? Это быстро.', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), const SizedBox(height: 24), FilledButton( onPressed: _isCreatingEnv ? null : _setupEnv, style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), ), child: _isCreatingEnv ? SizedBox( - width: 18, height: 18, - child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary), + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), ) - : const Text('Начать', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + : const Text( + 'Начать', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), ), ], ), @@ -382,7 +425,11 @@ class _CloudStorageScreenState extends State ); } - Widget _buildUploadingCenterHint(ColorScheme cs, double t, double availableWidth) { + Widget _buildUploadingCenterHint( + ColorScheme cs, + double t, + double availableWidth, + ) { final cardSide = availableWidth * _cardViewportFraction; return Center( child: Opacity( @@ -412,7 +459,9 @@ class _CloudStorageScreenState extends State ); if (i == 0 && _animateNewCard) { return _FadeScaleEntry( - key: ValueKey('${_files[0].messageId}_${_files[0].time}'), + key: ValueKey( + '${_files[0].messageId}_${_files[0].time}', + ), child: padded, ); } @@ -448,8 +497,10 @@ class _CloudStorageScreenState extends State const SizedBox(height: 8), Text( 'Загрузка ${(progress * 100).toStringAsFixed(0)}%', - style: - TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), ), ], ), @@ -556,11 +607,11 @@ class _UploadModeController { final AnimationController anim; _UploadModeController(TickerProvider vsync) - : anim = AnimationController( - vsync: vsync, - duration: _openDuration, - reverseDuration: _closeDuration, - ); + : anim = AnimationController( + vsync: vsync, + duration: _openDuration, + reverseDuration: _closeDuration, + ); bool get isOpen => anim.value > 0; @@ -706,10 +757,7 @@ class _DragDownHintState extends State<_DragDownHint> if (phase > _activeFraction) return (dy: 0, opacity: 0); final local = phase / _activeFraction; final eased = Curves.easeOutCubic.transform(local); - return ( - dy: _startY + eased * _travel, - opacity: (1 - local) * _peakOpacity, - ); + return (dy: _startY + eased * _travel, opacity: (1 - local) * _peakOpacity); } } @@ -738,9 +786,15 @@ class _FadeScaleEntryState extends State<_FadeScaleEntry> @override void initState() { super.initState(); - _c = AnimationController(vsync: this, duration: const Duration(milliseconds: 550)); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 550), + ); _scale = CurvedAnimation(parent: _c, curve: Curves.elasticOut); - _opacity = CurvedAnimation(parent: _c, curve: const Interval(0, 0.4, curve: Curves.easeIn)); + _opacity = CurvedAnimation( + parent: _c, + curve: const Interval(0, 0.4, curve: Curves.easeIn), + ); _c.forward(); } @@ -789,7 +843,7 @@ class _CloudFileCard extends StatelessWidget { final d = DateTime.fromMillisecondsSinceEpoch(millis); final now = DateTime.now(); if (d.year == now.year && d.month == now.month && d.day == now.day) { - return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + return formatClock(d); } return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}'; } @@ -805,7 +859,10 @@ class _CloudFileCard extends StatelessWidget { decoration: BoxDecoration( color: cs.surfaceContainerLow, borderRadius: BorderRadius.circular(16), - border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5), + border: Border.all( + color: cs.outlineVariant.withValues(alpha: 0.5), + width: 0.5, + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -834,7 +891,10 @@ class _CloudFileCard extends StatelessWidget { const SizedBox(width: 4), Text( _formatTime(file.time), - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 10, + ), ), ], ), @@ -889,18 +949,23 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { chatId: f.chatId, messageId: f.messageId, ); - if (mounted) setState(() { _link = result; _loading = false; }); + if (mounted) { + setState(() { + _link = result; + _loading = false; + }); + } } static String _formatSize(int? bytes) { if (bytes == null) return '—'; - if (bytes < 1024) return '$bytes Б'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ'; + return formatBytes(bytes); } static String _formatExpiry(int expiresMs) { - final remaining = DateTime.fromMillisecondsSinceEpoch(expiresMs).difference(DateTime.now()); + final remaining = DateTime.fromMillisecondsSinceEpoch( + expiresMs, + ).difference(DateTime.now()); if (remaining.isNegative) return 'истекла'; final h = remaining.inHours; final m = remaining.inMinutes % 60; @@ -913,7 +978,8 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final f = widget.file; - final isExpired = _link == null || + final isExpired = + _link == null || _link!.expires <= DateTime.now().millisecondsSinceEpoch; return Container( @@ -922,25 +988,25 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), padding: EdgeInsets.fromLTRB( - 24, 16, 24, + 24, + 16, + 24, MediaQuery.of(context).viewInsets.bottom + 32, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Container( - width: 36, height: 4, - decoration: BoxDecoration( - color: cs.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), + const Center(child: SheetGrabber(margin: EdgeInsets.zero)), + const SizedBox(height: 20), + Text( + f.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 20), - Text(f.name, - style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w700)), const SizedBox(height: 12), _InfoRow(label: 'ID файла', value: f.fileId?.toString() ?? '—'), const SizedBox(height: 6), @@ -952,16 +1018,27 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { children: [ Expanded( child: isExpired - ? Text('Ссылки пока нет. Создайте.', - style: TextStyle(color: cs.error, fontSize: 13)) - : Text('Ссылка истечет ${_formatExpiry(_link!.expires)}', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ? Text( + 'Ссылки пока нет. Создайте.', + style: TextStyle(color: cs.error, fontSize: 13), + ) + : Text( + 'Ссылка истечет ${_formatExpiry(_link!.expires)}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), ), const SizedBox(width: 8), _loading ? SizedBox( - width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: cs.primary), + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), ) : IconButton( icon: Icon( @@ -974,8 +1051,13 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { onPressed: isExpired ? _generateLink : () { - Clipboard.setData(ClipboardData(text: _link!.url)); - showCustomNotification(context, 'Ссылка скопирована'); + Clipboard.setData( + ClipboardData(text: _link!.url), + ); + showCustomNotification( + context, + 'Ссылка скопирована', + ); }, ), ], @@ -996,11 +1078,18 @@ class _InfoRow extends StatelessWidget { final cs = Theme.of(context).colorScheme; return Row( children: [ - Text('$label: ', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + Text( + '$label: ', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), Expanded( child: Text( value, - style: TextStyle(color: cs.onSurface, fontSize: 13, fontWeight: FontWeight.w500), + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w500, + ), overflow: TextOverflow.ellipsis, ), ), @@ -1053,24 +1142,25 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), padding: EdgeInsets.fromLTRB( - 24, 16, 24, + 24, + 16, + 24, MediaQuery.of(context).viewInsets.bottom + 32, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Container( - width: 36, height: 4, - decoration: BoxDecoration( - color: cs.outlineVariant, borderRadius: BorderRadius.circular(2), - ), + const Center(child: SheetGrabber(margin: EdgeInsets.zero)), + const SizedBox(height: 20), + Text( + 'Отправить по ID', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 20), - Text('Отправить по ID', - style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w700)), const SizedBox(height: 12), TextField( controller: _controller, @@ -1087,7 +1177,10 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), ), const SizedBox(height: 16), @@ -1095,14 +1188,23 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { onPressed: _sending ? null : _submit, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), child: _sending ? SizedBox( - width: 18, height: 18, - child: CircularProgressIndicator(strokeWidth: 2, color: cs.onPrimary), + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), ) - : const Text('Отправить', style: TextStyle(fontWeight: FontWeight.w600)), + : const Text( + 'Отправить', + style: TextStyle(fontWeight: FontWeight.w600), + ), ), ], ), diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index f07c0e4..10ebc4b 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -10,10 +10,12 @@ import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/protocol/packet.dart'; +import '../../../core/utils/format.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/media_cache.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; import '../../widgets/login_success_screen.dart'; import '../calls/call_screen.dart'; @@ -53,7 +55,7 @@ class _DebugMenuScreenState extends State { _clearingCache = false; _cacheSize = 0; }); - showCustomNotification(context, 'Кэш очищен (${_formatBytes(freed)})'); + showCustomNotification(context, 'Кэш очищен (${formatBytes(freed)})'); } void _pickCacheLimit() { @@ -61,9 +63,7 @@ class _DebugMenuScreenState extends State { showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, @@ -106,16 +106,7 @@ class _DebugMenuScreenState extends State { } String _limitLabel(int bytes) => - bytes <= 0 ? 'Без лимита' : _formatBytes(bytes); - - String _formatBytes(int bytes) { - if (bytes < 1024) return '$bytes Б'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} ГБ'; - } + bytes <= 0 ? 'Без лимита' : formatBytes(bytes); @override void dispose() { @@ -133,7 +124,10 @@ class _DebugMenuScreenState extends State { _errors.clear(); }); - Future tryProbe(String label, Future Function() probe) async { + Future tryProbe( + String label, + Future Function() probe, + ) async { try { final res = await probe(); logger.i('debug-search $label($id): $res'); @@ -147,11 +141,15 @@ class _DebugMenuScreenState extends State { await Future.wait([ tryProbe('contactInfo', () async { - final p = await api.sendRequest(Opcode.contactInfo, {'contactIds': [id]}); + final p = await api.sendRequest(Opcode.contactInfo, { + 'contactIds': [id], + }); return p.payload; }), tryProbe('chatInfo', () async { - final p = await api.sendRequest(Opcode.chatInfo, {'chatIds': [id]}); + final p = await api.sendRequest(Opcode.chatInfo, { + 'chatIds': [id], + }); return p.payload; }), tryProbe('publicSearch', () => ChatsModule.searchById(api, id)), @@ -704,7 +702,7 @@ class _DebugMenuScreenState extends State { Text( _clearingCache ? 'Очистка…' - : 'Занято: ${_formatBytes(_cacheSize)}', + : 'Занято: ${formatBytes(_cacheSize)}', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -958,7 +956,10 @@ class _DebugMenuScreenState extends State { padding: const EdgeInsets.all(12), child: Text( 'Ничего не найдено', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), ), ), for (final hit in _hits) ...[ @@ -1152,7 +1153,11 @@ class _SearchResultCard extends StatelessWidget { ), IconButton( tooltip: 'Скопировать id', - icon: Icon(Symbols.content_copy, size: 18, color: cs.onSurfaceVariant), + icon: Icon( + Symbols.content_copy, + size: 18, + color: cs.onSurfaceVariant, + ), onPressed: () async { await Clipboard.setData(ClipboardData(text: hit.id.toString())); if (context.mounted) { @@ -1292,10 +1297,7 @@ class _ErrorChip extends StatelessWidget { Expanded( child: Text( '$label: $message', - style: TextStyle( - color: cs.onErrorContainer, - fontSize: 12, - ), + style: TextStyle(color: cs.onErrorContainer, fontSize: 12), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -1347,4 +1349,4 @@ class _DebugCallButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index ab9b715..067b6c7 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -6,9 +6,11 @@ import 'package:flutter/foundation.dart' import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/utils/format.dart'; import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show SessionInfo; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; import 'web_qr_scan_screen.dart'; class DevicesScreen extends StatefulWidget { @@ -125,16 +127,7 @@ class _DevicesScreenState extends State mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(2), - ), - ), - ), + const Center(child: SheetGrabber(margin: EdgeInsets.zero)), const SizedBox(height: 20), Text( 'Вход по QR', @@ -158,8 +151,7 @@ class _DevicesScreenState extends State children: [ Expanded( child: OutlinedButton( - onPressed: () => - Navigator.of(sheetContext).pop(false), + onPressed: () => Navigator.of(sheetContext).pop(false), child: Text( 'Отмена', style: TextStyle(color: cs.onSurface), @@ -169,8 +161,7 @@ class _DevicesScreenState extends State const SizedBox(width: 12), Expanded( child: FilledButton( - onPressed: () => - Navigator.of(sheetContext).pop(true), + onPressed: () => Navigator.of(sheetContext).pop(true), child: const Text('Войти'), ), ), @@ -186,7 +177,8 @@ class _DevicesScreenState extends State } Future _startWebQrAuth() async { - final canScan = !kIsWeb && + final canScan = + !kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); @@ -310,29 +302,14 @@ class _DevicesScreenState extends State if (now.year == date.year && now.month == date.month && now.day == date.day) { - return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + return formatClock(date); } - final months = [ - 'янв.', - 'февр.', - 'мар.', - 'апр.', - 'мая', - 'июня', - 'июля', - 'авг.', - 'сент.', - 'окт.', - 'нояб.', - 'дек.', - ]; - if (now.year == date.year) { - return '${date.day} ${months[date.month - 1]}'; + return '${date.day} ${kRuMonthsShort[date.month - 1]}'; } - return '${date.day}.${date.month.toString().padLeft(2, '0')}.${date.year}'; + return formatDateNumeric(date); } @override diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart index 200bc49..a1da0b7 100644 --- a/lib/frontend/screens/profile/edit_profile_screen.dart +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -7,8 +7,6 @@ import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show accountModule, fileUploader, KometApp; import '../../widgets/custom_notification.dart'; -const int _maxAvatarBytes = 8 * 1024 * 1024; - class EditProfileScreen extends StatefulWidget { const EditProfileScreen({super.key}); @@ -62,7 +60,9 @@ class _EditProfileScreenState extends State { try { final newProfile = await accountModule.updateProfileName( firstName, - _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), + _lastNameController.text.trim().isEmpty + ? null + : _lastNameController.text.trim(), ); _avatarUrl = newProfile.baseUrl; _photoId = newProfile.photoId; @@ -92,8 +92,10 @@ class _EditProfileScreenState extends State { if (mounted) showCustomNotification(context, 'Не удалось прочитать файл'); return; } - if (bytes.length > _maxAvatarBytes) { - if (mounted) showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); + if (bytes.length > kMaxAvatarBytes) { + if (mounted) { + showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); + } return; } if (!mounted) return; @@ -183,7 +185,10 @@ class _EditProfileScreenState extends State { ) : Text( l10n?.editProfileSave ?? 'Save', - style: TextStyle(color: cs.primary, fontWeight: FontWeight.w600), + style: TextStyle( + color: cs.primary, + fontWeight: FontWeight.w600, + ), ), ), ], @@ -214,7 +219,8 @@ class _EditProfileScreenState extends State { alignment: Alignment.center, child: Text( _firstNameController.text.isNotEmpty - ? _firstNameController.text[0].toUpperCase() + ? _firstNameController.text[0] + .toUpperCase() : '?', style: TextStyle( color: cs.onPrimaryContainer, @@ -234,7 +240,11 @@ class _EditProfileScreenState extends State { shape: BoxShape.circle, ), child: IconButton( - icon: Icon(Symbols.camera_alt, color: cs.onPrimary, size: 20), + icon: Icon( + Symbols.camera_alt, + color: cs.onPrimary, + size: 20, + ), onPressed: _changeAvatar, ), ), @@ -274,13 +284,21 @@ class _EditProfileScreenState extends State { ); } - Widget _buildTextField(String label, TextEditingController controller, ColorScheme cs, {bool enabled = true}) { + Widget _buildTextField( + String label, + TextEditingController controller, + ColorScheme cs, { + bool enabled = true, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 4, bottom: 6), - child: Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + child: Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), ), TextField( controller: controller, @@ -292,10 +310,13 @@ class _EditProfileScreenState extends State { borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), ), ), ], ); } -} \ No newline at end of file +} diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index 58febca..04477cb 100644 --- a/lib/frontend/screens/profile/info_screen.dart +++ b/lib/frontend/screens/profile/info_screen.dart @@ -5,6 +5,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; import '../../../l10n/app_localizations.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/section_header.dart'; class InfoScreen extends StatefulWidget { const InfoScreen({super.key}); @@ -65,13 +66,13 @@ class _InfoScreenState extends State { body: _isLoading ? const Center(child: CircularProgressIndicator()) : _info == null - ? Center( - child: Text( - 'No data', - style: TextStyle(color: cs.onSurfaceVariant), - ), - ) - : _buildContent(cs, l10n!), + ? Center( + child: Text( + 'No data', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ) + : _buildContent(cs, l10n!), ); } @@ -114,29 +115,49 @@ class _InfoScreenState extends State { return ListView( padding: const EdgeInsets.all(16), children: [ - _buildSectionTitle(l10n.infoAccountSection, cs), - ...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs)), + SectionHeader(l10n.infoAccountSection), + ...accountKeys.entries.map( + (e) => + _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs), + ), const SizedBox(height: 16), - _buildSectionTitle(l10n.infoServerSection, cs), - ...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key], e.key), cs)), + SectionHeader(l10n.infoServerSection), + ...serverKeys.entries.map( + (e) => _buildRow( + e.key, + e.value, + _formatValue(server?[e.key], e.key), + cs, + ), + ), const SizedBox(height: 8), - _buildSectionTitle(l10n.infoYMapSection, cs), + SectionHeader(l10n.infoYMapSection), _buildRow('tile', l10n.infoTile, yMap?['tile']?.toString() ?? '-', cs), - _buildRow('geocoder', l10n.infoGeocoder, yMap?['geocoder']?.toString() ?? '-', cs), - _buildRow('static', l10n.infoStatic, yMap?['static']?.toString() ?? '-', cs), + _buildRow( + 'geocoder', + l10n.infoGeocoder, + yMap?['geocoder']?.toString() ?? '-', + cs, + ), + _buildRow( + 'static', + l10n.infoStatic, + yMap?['static']?.toString() ?? '-', + cs, + ), const SizedBox(height: 8), - _buildSectionTitle(l10n.infoFileUploadTypes, cs), + SectionHeader(l10n.infoFileUploadTypes), _buildListRow(server?['file-upload-unsupported-types'] as List?, cs), const SizedBox(height: 8), - _buildSectionTitle(l10n.infoWhiteListLinks, cs), + SectionHeader(l10n.infoWhiteListLinks), _buildListRow(server?['white-list-links'] as List?, cs), const SizedBox(height: 8), - _buildSectionTitle(l10n.infoUserSection, cs), + SectionHeader(l10n.infoUserSection), if (user != null) ...user.entries .where((e) => e.value != null) @@ -147,21 +168,6 @@ class _InfoScreenState extends State { ); } - Widget _buildSectionTitle(String title, ColorScheme cs) { - return Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), - child: Text( - title, - style: TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - ); - } - Widget _buildRow(String key, String label, String value, ColorScheme cs) { return Container( margin: const EdgeInsets.only(bottom: 1), @@ -224,7 +230,10 @@ class _InfoScreenState extends State { children: items .map( (item) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), decoration: BoxDecoration( color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), @@ -250,7 +259,10 @@ class _InfoScreenState extends State { if (key == 'edit-timeout' && value is int && value > 0) { final weeks = value ~/ 604800; final days = (value % 604800) ~/ 86400; - if (weeks > 0) return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim(); + if (weeks > 0) { + return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}' + .trim(); + } final h = value ~/ 3600; final m = (value % 3600) ~/ 60; if (h > 0) return '${h}h ${m}m'; @@ -279,4 +291,4 @@ class _InfoScreenState extends State { if ((m == 2 || m == 3 || m == 4) && (n < 10 || n > 20)) return 'дн'; return 'дн'; } -} \ No newline at end of file +} diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index ee5b9f5..30ae97d 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/section_header.dart'; +import '../../widgets/sheet_helpers.dart'; + class NotificationsScreen extends StatefulWidget { const NotificationsScreen({super.key}); @@ -29,9 +32,7 @@ class _NotificationsScreenState extends State { final picked = await showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (context) { return SafeArea( child: Padding( @@ -67,10 +68,7 @@ class _NotificationsScreenState extends State { ), title: Text( s, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - ), + style: TextStyle(color: cs.onSurface, fontSize: 16), ), ), ], @@ -90,17 +88,18 @@ class _NotificationsScreenState extends State { return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E( - titleText: 'Уведомления', - backgroundColor: cs.surface, - ), + appBar: AppBarM3E(titleText: 'Уведомления', backgroundColor: cs.surface), body: SafeArea( top: false, child: ListView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), children: [ - _sectionHeader(cs, 'FKM'), + const SectionHeader( + 'FKM', + padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), _card(cs, [ _toggleRow( cs, @@ -113,7 +112,11 @@ class _NotificationsScreenState extends State { ), ]), const SizedBox(height: 20), - _sectionHeader(cs, 'Настройки уведомлений'), + const SectionHeader( + 'Настройки уведомлений', + padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), _card(cs, [ _toggleRow( cs, @@ -140,7 +143,11 @@ class _NotificationsScreenState extends State { ), ]), const SizedBox(height: 20), - _sectionHeader(cs, 'Звук'), + const SectionHeader( + 'Звук', + padding: EdgeInsets.fromLTRB(8, 0, 8, 8), + fontSize: 14, + ), _card(cs, [ _tappableRow( cs, @@ -156,21 +163,6 @@ class _NotificationsScreenState extends State { ); } - Widget _sectionHeader(ColorScheme cs, String title) { - return Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - child: Text( - title, - style: TextStyle( - color: cs.primary, - fontSize: 14, - fontWeight: FontWeight.w600, - letterSpacing: 0.2, - ), - ), - ); - } - Widget _card(ColorScheme cs, List children) { return Container( decoration: BoxDecoration( @@ -275,10 +267,7 @@ class _NotificationsScreenState extends State { ), Text( trailingText, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - ), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(width: 6), Icon(Symbols.chevron_right, color: cs.outline, size: 20), diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index 84ee9b9..12e95f2 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show TwoFactorDetails; import '../../../core/storage/app_database.dart'; +import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; class PasswordEntryScreen extends StatefulWidget { @@ -270,36 +271,22 @@ class _PasswordEntryScreenState extends State { ); } - void _showRemoveConfirmation(BuildContext context, ColorScheme cs) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Text('Удалить пароль?', style: TextStyle(color: cs.onSurface)), - content: Text( + Future _showRemoveConfirmation( + BuildContext context, + ColorScheme cs, + ) async { + final confirmed = await showConfirmDialog( + context, + title: 'Удалить пароль?', + message: 'Вы уверены, что хотите удалить пароль для входа? Это ослабит защиту вашего аккаунта.', - style: TextStyle(color: cs.onSurfaceVariant), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('Отмена', style: TextStyle(color: cs.primary)), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const TwoFactorRemoveScreen(), - ), - ); - }, - child: Text('Удалить', style: TextStyle(color: cs.error)), - ), - ], - ), + confirmLabel: 'Удалить', + destructive: true, + ); + if (!confirmed || !context.mounted) return; + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const TwoFactorRemoveScreen()), ); } } @@ -813,10 +800,7 @@ class _TwoFactorManageScreenState extends State { style: TextStyle(color: cs.onErrorContainer), ), ), - _PasswordField( - controller: _passwordController, - hintText: 'Пароль', - ), + _PasswordField(controller: _passwordController, hintText: 'Пароль'), const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -1414,10 +1398,7 @@ class _TwoFactorRemoveScreenState extends State { style: TextStyle(color: cs.onErrorContainer), ), ), - _PasswordField( - controller: _passwordController, - hintText: 'Пароль', - ), + _PasswordField(controller: _passwordController, hintText: 'Пароль'), const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -1457,10 +1438,7 @@ class _PasswordField extends StatefulWidget { final TextEditingController controller; final String hintText; - const _PasswordField({ - required this.controller, - required this.hintText, - }); + const _PasswordField({required this.controller, required this.hintText}); @override State<_PasswordField> createState() => _PasswordFieldState(); diff --git a/lib/frontend/screens/profile/performance_screen.dart b/lib/frontend/screens/profile/performance_screen.dart index de98406..6d146f3 100644 --- a/lib/frontend/screens/profile/performance_screen.dart +++ b/lib/frontend/screens/profile/performance_screen.dart @@ -3,6 +3,7 @@ import 'package:m3e_collection/m3e_collection.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/utils/haptics.dart'; +import '../../widgets/confirm_dialog.dart'; class PerformanceScreen extends StatefulWidget { const PerformanceScreen({super.key}); @@ -25,7 +26,8 @@ class _PerformanceScreenState extends State { } bool _isInSafeZone(double v) => - v >= AppCacheExtent.lowWarnThreshold && v < AppCacheExtent.highWarnThreshold; + v >= AppCacheExtent.lowWarnThreshold && + v < AppCacheExtent.highWarnThreshold; void _onChanged(double v) { setState(() { @@ -41,8 +43,7 @@ class _PerformanceScreenState extends State { if (inLow && !_lowWarnDismissed) { final ok = await _showWarning( - text: - 'Производительность приложения может снизиться, вы уверены?', + text: 'Производительность приложения может снизиться, вы уверены?', ); if (ok) { _lowWarnDismissed = true; @@ -73,37 +74,13 @@ class _PerformanceScreenState extends State { await AppCacheExtent.save(v); } - Future _showWarning({required String text}) async { - final cs = Theme.of(context).colorScheme; - final res = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - content: Text( - text, - style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text( - 'Нет', - style: TextStyle(color: cs.onSurfaceVariant), - ), - ), - FilledButton.tonal( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Да'), - ), - ], - ); - }, + Future _showWarning({required String text}) { + return showConfirmDialog( + context, + message: text, + confirmLabel: 'Да', + cancelLabel: 'Нет', ); - return res ?? false; } @override diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 680234b..a4bf972 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -5,7 +5,9 @@ import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show PrivacyConfig, BlockedContact; import '../../../core/storage/app_database.dart'; +import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; import 'password_entry_screen.dart'; class SecurityScreen extends StatefulWidget { @@ -539,14 +541,7 @@ class _SecurityScreenState extends State mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), + const SheetGrabber(margin: EdgeInsets.zero), const SizedBox(height: 16), Text( title, @@ -598,37 +593,20 @@ class _SecurityScreenState extends State ); } - void _showHiddenStatusSheet(BuildContext context, ColorScheme cs) { + Future _showHiddenStatusSheet( + BuildContext context, + ColorScheme cs, + ) async { final currentValue = _privacyConfig?.hidden == true ? 'NONE' : 'CONTACTS'; if (currentValue == 'NONE') { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), - content: Text( - 'Вы не сможете видеть статусы посещения других пользователей.', - style: TextStyle(color: cs.onSurfaceVariant), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('Отмена', style: TextStyle(color: cs.primary)), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - _updateSetting('HIDDEN', false); - }, - child: Text('Да', style: TextStyle(color: cs.primary)), - ), - ], - ), + final confirmed = await showConfirmDialog( + context, + title: 'Вы уверены?', + message: 'Вы не сможете видеть статусы посещения других пользователей.', + confirmLabel: 'Да', ); + if (confirmed) _updateSetting('HIDDEN', false); return; } @@ -644,14 +622,7 @@ class _SecurityScreenState extends State mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), + const SheetGrabber(margin: EdgeInsets.zero), const SizedBox(height: 16), Text( 'Видеть статус «в сети»', @@ -683,32 +654,17 @@ class _SecurityScreenState extends State ); } - void _showHiddenStatusConfirmDialog(BuildContext context, ColorScheme cs) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), - content: Text( - 'Вы не сможете видеть статусы посещения других пользователей.', - style: TextStyle(color: cs.onSurfaceVariant), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('Отмена', style: TextStyle(color: cs.primary)), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - _updateSetting('HIDDEN', true); - }, - child: Text('Да', style: TextStyle(color: cs.primary)), - ), - ], - ), + Future _showHiddenStatusConfirmDialog( + BuildContext context, + ColorScheme cs, + ) async { + final confirmed = await showConfirmDialog( + context, + title: 'Вы уверены?', + message: 'Вы не сможете видеть статусы посещения других пользователей.', + confirmLabel: 'Да', ); + if (confirmed) _updateSetting('HIDDEN', true); } Widget _buildOptionSheetItem( diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index d8c7ba1..dd43344 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -12,6 +11,8 @@ import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/info_action_sheet.dart'; +import '../../widgets/komet_avatar.dart'; +import '../../widgets/sheet_helpers.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; import 'cloud_storage_screen.dart'; @@ -144,9 +145,7 @@ class _SettingsTabState extends State { final confirmed = await showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + shape: kSheetShape, builder: (ctx) { return SafeArea( child: Padding( @@ -245,11 +244,14 @@ class _SettingsTabState extends State { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), -child: _buildSection( + child: _buildSection( context, cs, items: [ - const _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), + const _SettingsItem( + icon: Symbols.badge, + label: 'Цифровой ID', + ), const _SettingsItem( icon: Symbols.language, label: 'Войти в Сферум', @@ -344,15 +346,9 @@ child: _buildSection( context: context, isScrollControlled: true, backgroundColor: cs.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(24), - ), - ), + shape: kSheetShape, builder: (_) { - return SafeArea( - child: const ProxySettingsSheet(), - ); + return SafeArea(child: const ProxySettingsSheet()); }, ); }, @@ -407,10 +403,7 @@ child: _buildSection( child: Align( alignment: Alignment.topCenter, heightFactor: animation.value.clamp(0.0, 1.0), - child: FadeTransition( - opacity: animation, - child: child, - ), + child: FadeTransition(opacity: animation, child: child), ), ); }, @@ -418,10 +411,7 @@ child: _buildSection( return Stack( alignment: Alignment.topCenter, clipBehavior: Clip.none, - children: [ - ...previousChildren, - ?currentChild, - ], + children: [...previousChildren, ?currentChild], ); }, child: _debugMenuVisible @@ -557,18 +547,11 @@ child: _buildSection( width: 2.5, ), ), - child: ClipOval( - child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: _profile!.baseUrl!, - fit: BoxFit.cover, - memCacheWidth: 240, - memCacheHeight: 240, - fadeInDuration: const Duration(milliseconds: 120), - errorWidget: (context, url, error) => - _buildPlaceholderAvatar(cs, name), - ) - : _buildPlaceholderAvatar(cs, name), + child: KometAvatar( + name: name, + imageUrl: _profile?.baseUrl, + size: 88, + fontSize: 32, ), ), const SizedBox(height: 14), @@ -614,21 +597,6 @@ child: _buildSection( ); } - Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { - return Container( - color: cs.primaryContainer, - alignment: Alignment.center, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 32, - fontWeight: FontWeight.bold, - ), - ), - ); - } - Widget _buildSection( BuildContext context, ColorScheme cs, { diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index 48434c0..bf173af 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -14,6 +14,7 @@ import '../../../core/storage/token_storage.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; import '../../widgets/info_action_sheet.dart'; +import '../../widgets/section_header.dart'; import '../auth/login_screen.dart'; enum SpoofingMethod { partial, full } @@ -621,19 +622,6 @@ class _SpoofScreenState extends State { ); } - Widget _buildSectionHeader(BuildContext context, String title) { - return Padding( - padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ); - } - Widget _buildMainDataCard() { final l10n = AppLocalizations.of(context)!; return Card( @@ -642,7 +630,11 @@ class _SpoofScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader(context, l10n.spoofMainSectionTitle), + SectionHeader( + l10n.spoofMainSectionTitle, + padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), + fontSize: 22, + ), TextField( controller: _deviceNameController, decoration: _inputDecoration( @@ -672,7 +664,11 @@ class _SpoofScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader(context, l10n.spoofRegionalSectionTitle), + SectionHeader( + l10n.spoofRegionalSectionTitle, + padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), + fontSize: 22, + ), TextField( controller: _screenController, decoration: _inputDecoration( @@ -721,7 +717,11 @@ class _SpoofScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader(context, l10n.spoofIdentifiersSectionTitle), + SectionHeader( + l10n.spoofIdentifiersSectionTitle, + padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), + fontSize: 22, + ), _buildDescriptionTile( icon: Icons.info_outline, color: Theme.of(context).colorScheme.tertiary, diff --git a/lib/frontend/widgets/account_switcher_overlay.dart b/lib/frontend/widgets/account_switcher_overlay.dart index 983692c..7012edd 100644 --- a/lib/frontend/widgets/account_switcher_overlay.dart +++ b/lib/frontend/widgets/account_switcher_overlay.dart @@ -1,6 +1,5 @@ import 'dart:ui' as ui; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -8,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/haptics.dart'; +import 'komet_avatar.dart'; class AccountSwitcherController extends ChangeNotifier { Offset? pointer; @@ -301,9 +301,7 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer> highlighted: _hoveredIndex == i, active: _accounts[i].id == _activeId, ), - _AddAccountRow( - highlighted: _hoveredIndex == _accounts.length, - ), + _AddAccountRow(highlighted: _hoveredIndex == _accounts.length), ], ), ), @@ -363,17 +361,15 @@ class _AccountRow extends StatelessWidget { ) : null, ), - child: ClipOval( - child: profile.baseUrl != null && profile.baseUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: profile.baseUrl!, - fit: BoxFit.cover, - memCacheWidth: 96, - memCacheHeight: 96, - errorWidget: (_, __, ___) => - _initialAvatar(cs, fullName, highlighted), - ) - : _initialAvatar(cs, fullName, highlighted), + child: KometAvatar( + name: fullName, + imageUrl: profile.baseUrl, + size: 36, + backgroundColor: highlighted + ? cs.primaryContainer + : cs.surfaceContainerHighest, + foregroundColor: cs.onSurface, + fontSize: 16, ), ), const SizedBox(width: 12), @@ -418,21 +414,6 @@ class _AccountRow extends StatelessWidget { ), ); } - - Widget _initialAvatar(ColorScheme cs, String name, bool highlighted) { - return Container( - color: highlighted ? cs.primaryContainer : cs.surfaceContainerHighest, - alignment: Alignment.center, - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - ); - } } class _AddAccountRow extends StatelessWidget { diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 81d5bd6..1467aed 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/media/gallery_source.dart'; +import 'package:komet/core/utils/format.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; +import 'package:komet/frontend/widgets/sheet_helpers.dart'; import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; const List _navItems = [ @@ -126,7 +128,7 @@ class _AttachmentSheetState extends State { clipBehavior: Clip.antiAlias, child: Column( children: [ - _buildHandle(cs), + const SheetGrabber(), Expanded( child: Stack( children: [ @@ -178,18 +180,6 @@ class _AttachmentSheetState extends State { static const double _barHeight = SlidingPillNav.height + _pillMargin; static const Duration _navAnim = Duration(milliseconds: 300); - Widget _buildHandle(ColorScheme cs) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 10), - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ); - } - Widget _buildPages( ScrollController scrollController, ColorScheme cs, @@ -597,7 +587,7 @@ class _GalleryTileState extends State<_GalleryTile> { ), if (item.duration != null) Text( - _formatDuration(item.duration!), + formatDurationMmSs(item.duration!), style: const TextStyle( color: Colors.white, fontSize: 11, @@ -617,12 +607,6 @@ class _GalleryTileState extends State<_GalleryTile> { ), ); } - - String _formatDuration(Duration d) { - final m = d.inMinutes; - final s = (d.inSeconds % 60).toString().padLeft(2, '0'); - return '$m:$s'; - } } class _SelectionCheck extends StatelessWidget { diff --git a/lib/frontend/widgets/confirm_dialog.dart b/lib/frontend/widgets/confirm_dialog.dart new file mode 100644 index 0000000..e158c4d --- /dev/null +++ b/lib/frontend/widgets/confirm_dialog.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +/// Shared confirmation dialog. Returns true if confirmed, false otherwise. +Future showConfirmDialog( + BuildContext context, { + String? title, + required String message, + String confirmLabel = 'OK', + String cancelLabel = 'Отмена', + bool destructive = false, +}) async { + final cs = Theme.of(context).colorScheme; + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + title: title == null + ? null + : Text(title, style: TextStyle(color: cs.onSurface)), + content: Text( + message, + style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.35), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(cancelLabel, style: TextStyle(color: cs.onSurfaceVariant)), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(context).pop(true), + style: destructive + ? FilledButton.styleFrom( + backgroundColor: cs.errorContainer, + foregroundColor: cs.onErrorContainer, + ) + : null, + child: Text(confirmLabel), + ), + ], + ), + ); + return result ?? false; +} diff --git a/lib/frontend/widgets/komet_avatar.dart b/lib/frontend/widgets/komet_avatar.dart new file mode 100644 index 0000000..000eea7 --- /dev/null +++ b/lib/frontend/widgets/komet_avatar.dart @@ -0,0 +1,58 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +/// Circular avatar: shows [imageUrl] when available, otherwise the first letter +/// of [name] on a colored background. Falls back to the letter on image error. +class KometAvatar extends StatelessWidget { + final String name; + final String? imageUrl; + final double size; + final Color? backgroundColor; + final Color? foregroundColor; + final double? fontSize; + + const KometAvatar({ + super.key, + required this.name, + required this.size, + this.imageUrl, + this.backgroundColor, + this.foregroundColor, + this.fontSize, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bg = backgroundColor ?? cs.primaryContainer; + final fg = foregroundColor ?? cs.onPrimaryContainer; + final letter = name.isNotEmpty ? name[0].toUpperCase() : '?'; + final placeholder = Center( + child: Text( + letter, + style: TextStyle( + color: fg, + fontSize: fontSize ?? size * 0.4, + fontWeight: FontWeight.bold, + ), + ), + ); + final url = imageUrl; + final cache = (size * 3).round(); + return Container( + width: size, + height: size, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(shape: BoxShape.circle, color: bg), + child: (url != null && url.isNotEmpty) + ? CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + memCacheWidth: cache, + memCacheHeight: cache, + errorWidget: (_, _, _) => placeholder, + ) + : placeholder, + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index a8f5ef9..2ca460e 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -7,6 +7,7 @@ import '../../backend/modules/messages.dart'; import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; import '../../core/utils/bubble_radius.dart'; +import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../core/utils/file_download.dart'; import '../../core/utils/media_cache.dart'; @@ -58,13 +59,14 @@ class MessageBubble extends StatelessWidget { static const Radius _photoRadius = Radius.circular(photoBorderRadius); static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); - static const BorderRadius _reactionChipRadius = - BorderRadius.all(Radius.circular(10)); + static const BorderRadius _reactionChipRadius = BorderRadius.all( + Radius.circular(10), + ); static Color bubbleTextColor(BuildContext context) => Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black; + ? Colors.white + : Colors.black; final CachedMessage message; final bool isMe; @@ -108,13 +110,15 @@ class MessageBubble extends StatelessWidget { final hasPrevFromMe = prevMessage?.senderId == message.senderId && !prevMessage!.isControl; - final prevTimeDiff = - hasPrevFromMe ? message.time - prevMessage!.time : 999999999; + final prevTimeDiff = hasPrevFromMe + ? message.time - prevMessage!.time + : 999999999; final hasNextFromMe = nextMessage?.senderId == message.senderId && !nextMessage!.isControl; - final nextTimeDiff = - hasNextFromMe ? nextMessage!.time - message.time : 999999999; + final nextTimeDiff = hasNextFromMe + ? nextMessage!.time - message.time + : 999999999; final groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000; final groupedWithNext = hasNextFromMe && nextTimeDiff < 300000; @@ -133,9 +137,11 @@ class MessageBubble extends StatelessWidget { if (first is ForwardedMessageAttachment) { final fwd = first; final hasContact = fwd.originalContact != null; - final hasPhoto = fwd.originalAttachments != null && + final hasPhoto = + fwd.originalAttachments != null && fwd.originalAttachments!.any((a) => a is PhotoAttachment); - final hasOther = fwd.originalAttachments != null && + final hasOther = + fwd.originalAttachments != null && fwd.originalAttachments!.isNotEmpty; if (hasContact || hasPhoto || hasOther) return MessageType.attachment; return MessageType.text; @@ -219,8 +225,10 @@ class MessageBubble extends StatelessWidget { bool hasPhotoWithCaption, bool hasMultiplePhotosNoCaption, ) { - final isTop = shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle; - final isBottom = shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle; + final isTop = + shape == BubbleShape.singleTop || shape == BubbleShape.singleMiddle; + final isBottom = + shape == BubbleShape.singleBottom || shape == BubbleShape.singleMiddle; return computeBubbleRadius( isMe: isMe, isTop: isTop, @@ -238,7 +246,11 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) { return CircleAvatar( radius: 15, - backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), backgroundColor: cs.primaryContainer, ); } @@ -280,36 +292,35 @@ class MessageBubble extends StatelessWidget { final padding = _paddingFor(contentType, shape); final showAvatarSlot = !isMe; - final showAvatar = showAvatarSlot && + final showAvatar = + showAvatarSlot && chatType == "CHAT" && nextMessage?.senderId != message.senderId; final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75; - final bubbleColor = - isMe ? cs.primaryContainer : cs.surfaceContainerHighest; + final bubbleColor = isMe ? cs.primaryContainer : cs.surfaceContainerHighest; _BubbleCtx makeCtx() => _BubbleCtx( - context: context, - cs: cs, - text: textColor, - shape: shape, - contentType: contentType, - hasPhotoWithCaption: hasPhotoCap, - hasMultiplePhotosNoCaption: hasMultiPhotos, - reactionInfo: _resolveReactionInfo(), - ); + context: context, + cs: cs, + text: textColor, + shape: shape, + contentType: contentType, + hasPhotoWithCaption: hasPhotoCap, + hasMultiplePhotosNoCaption: hasMultiPhotos, + reactionInfo: _resolveReactionInfo(), + ); final Widget bubbleContent = reactionsListenable != null && contentType == MessageType.text - ? ValueListenableBuilder?>( - valueListenable: reactionsListenable!, - builder: (context, _, _) => _buildContent(makeCtx()), - ) - : _buildContent(makeCtx()); + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, _, _) => _buildContent(makeCtx()), + ) + : _buildContent(makeCtx()); final reactionsUnder = _reactionsUnderBubble(contentType); - final reactionsInside = - contentType != MessageType.text && !reactionsUnder; + final reactionsInside = contentType != MessageType.text && !reactionsUnder; return GestureDetector( onTap: Haptics.tap, @@ -322,8 +333,9 @@ class MessageBubble extends StatelessWidget { ), child: Align( child: Row( - mainAxisAlignment: - isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: isMe + ? MainAxisAlignment.end + : MainAxisAlignment.start, spacing: 8, crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -337,8 +349,9 @@ class MessageBubble extends StatelessWidget { backgroundColor: Color(0x00000000), ), Column( - crossAxisAlignment: - isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ ListenableBuilder( listenable: Listenable.merge([ @@ -540,7 +553,8 @@ class MessageBubble extends StatelessWidget { Widget _buildTextContent(_BubbleCtx ctx) { final attachments = message.attachments; - final isForwardedContact = attachments != null && + final isForwardedContact = + attachments != null && attachments.isNotEmpty && attachments.first is ForwardedMessageAttachment && (attachments.first as ForwardedMessageAttachment).originalContact != @@ -558,21 +572,18 @@ class MessageBubble extends StatelessWidget { ? _buildForwardedInlineText(ctx, forwarded) : Text( message.text ?? '', - style: TextStyle( - color: ctx.text, - fontSize: 16, - height: 1.3, - ), + style: TextStyle(color: ctx.text, fontSize: 16, height: 1.3), ); final metaWidget = Text( message.status == 'EDITED' - ? '${_formatTime(message.time)} ред.' - : _formatTime(message.time), + ? '${formatClock(DateTime.fromMillisecondsSinceEpoch(message.time))} ред.' + : formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)), style: TextStyle(color: ctx.dim, fontSize: 10), ); - final showSender = message.senderId != message.accountId && + final showSender = + message.senderId != message.accountId && prevMessage?.senderId != message.senderId && chatType == "CHAT"; @@ -604,10 +615,7 @@ class MessageBubble extends StatelessWidget { padding: const EdgeInsets.only(bottom: 2), child: metaWidget, ), - if (isMe) ...[ - const SizedBox(width: 4), - _buildStatusIcon(ctx), - ], + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], ], ), ], @@ -625,21 +633,18 @@ class MessageBubble extends StatelessWidget { style: TextStyle(color: ctx.text), ), Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible(child: textWidget), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: metaWidget, - ), - if (isMe) ...[ - const SizedBox(width: 4), - _buildStatusIcon(ctx), - ], - ], - ), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible(child: textWidget), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: metaWidget, + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], + ], + ), ], ); } @@ -667,7 +672,11 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), backgroundColor: ctx.cs.primaryContainer, ) else @@ -735,8 +744,9 @@ class MessageBubble extends StatelessWidget { if (fwd.originalContact != null) { return _buildForwardedContactContent(ctx, fwd); } - final photos = - fwd.originalAttachments?.whereType().toList(); + final photos = fwd.originalAttachments + ?.whereType() + .toList(); if (photos != null && photos.isNotEmpty) { return _buildForwardedPhotoContent(ctx, fwd, photos); } @@ -885,7 +895,11 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), backgroundColor: ctx.cs.primaryContainer, ) else @@ -954,7 +968,11 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), backgroundColor: ctx.cs.primaryContainer, ) else @@ -1007,10 +1025,12 @@ class MessageBubble extends StatelessWidget { final matchBottom = !ctx.hasPhotoWithCaption; final topR = matchTop ? _bigRadius : _photoRadius; - final bottomL = - matchBottom ? (isMe ? _bigRadius : _smallRadius) : _smallRadius; - final bottomR = - matchBottom ? (isMe ? _smallRadius : _bigRadius) : _smallRadius; + final bottomL = matchBottom + ? (isMe ? _bigRadius : _smallRadius) + : _smallRadius; + final bottomR = matchBottom + ? (isMe ? _smallRadius : _bigRadius) + : _smallRadius; return ClipRRect( borderRadius: BorderRadius.only( @@ -1123,8 +1143,8 @@ class MessageBubble extends StatelessWidget { Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; - final cachePx = - (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round(); + final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio) + .round(); return AspectRatio( aspectRatio: 1, child: Stack( @@ -1160,8 +1180,8 @@ class MessageBubble extends StatelessWidget { String overlay, ) { final imageUrl = photo.baseUrl ?? ''; - final cachePx = - (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round(); + final cachePx = (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio) + .round(); return AspectRatio( aspectRatio: 1, child: Stack( @@ -1270,8 +1290,11 @@ class MessageBubble extends StatelessWidget { color: Colors.black54, shape: BoxShape.circle, ), - child: const Icon(Symbols.play_arrow, - color: Colors.white, size: 30), + child: const Icon( + Symbols.play_arrow, + color: Colors.white, + size: 30, + ), ), ), Positioned.fill( @@ -1289,10 +1312,7 @@ class MessageBubble extends StatelessWidget { ); } - Future _playVideo( - BuildContext context, - MessageAttachment video, - ) async { + Future _playVideo(BuildContext context, MessageAttachment video) async { final videoId = (video as dynamic).videoId as int?; final token = (video as dynamic).videoToken as String?; if (videoId == null) { @@ -1335,125 +1355,123 @@ class MessageBubble extends StatelessWidget { Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) { final name = (file as dynamic).name as String? ?? 'File'; final size = (file as dynamic).size as int? ?? 0; - final sizeStr = _formatFileSize(size); + final sizeStr = formatBytes(size); final fileId = (file as dynamic).fileId as int?; final cacheName = '${fileId}_$name'; return IntrinsicWidth( child: Padding( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.primaryContainer, - borderRadius: BorderRadius.circular(10), + padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Symbols.description, + color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + size: 20, + ), ), - child: Icon( - Symbols.description, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 20, - ), - ), - const SizedBox(width: 10), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - name, - style: TextStyle( - color: ctx.text, - fontSize: 14, - fontWeight: FontWeight.w500, - height: 1.2, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - ValueListenableBuilder( - valueListenable: MediaDownloadProgress.notifier(cacheName), - builder: (context, progress, _) => Text( - progress != null - ? '${(progress * 100).round()}% · $sizeStr' - : sizeStr, + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, style: TextStyle( - color: ctx.dim, - fontSize: 12, + color: ctx.text, + fontSize: 14, + fontWeight: FontWeight.w500, height: 1.2, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ), - ], + const SizedBox(height: 2), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier( + cacheName, + ), + builder: (context, progress, _) => Text( + progress != null + ? '${(progress * 100).round()}% · $sizeStr' + : sizeStr, + style: TextStyle( + color: ctx.dim, + fontSize: 12, + height: 1.2, + ), + ), + ), + ], + ), ), - ), - const SizedBox(width: 12), - ValueListenableBuilder( - valueListenable: MediaDownloadProgress.notifier(cacheName), - builder: (context, progress, _) { - final downloading = progress != null; - return GestureDetector( - onTap: downloading - ? null - : () => _downloadFile(ctx.context, file, name), - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: downloading - ? Padding( - padding: const EdgeInsets.all(8), - child: CircularProgressIndicator( - strokeWidth: 2, - value: progress > 0 ? progress : null, + const SizedBox(width: 12), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) { + final downloading = progress != null; + return GestureDetector( + onTap: downloading + ? null + : () => _downloadFile(ctx.context, file, name), + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues( + alpha: 0.12, + ) + : ctx.cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: downloading + ? Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + ), + ) + : Icon( + Symbols.download, color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + size: 18, ), - ) - : Icon( - Symbols.download, - color: isMe - ? ctx.cs.onPrimaryContainer - : ctx.cs.primary, - size: 18, - ), - ), - ); - }, - ), - ], - ), - _buildMeta(ctx), - ], + ), + ); + }, + ), + ], + ), + _buildMeta(ctx), + ], + ), ), - ), ); } - String _formatFileSize(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB'; - return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} МБ'; - } - Widget _buildStickerAttachment(_BubbleCtx ctx, MessageAttachment sticker) { final url = sticker.baseUrl ?? ''; final preview = sticker.previewData ?? ''; @@ -1533,8 +1551,7 @@ class MessageBubble extends StatelessWidget { ) : Icon( Symbols.person, - color: - isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, + color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, size: 24, ), ), @@ -1559,11 +1576,7 @@ class MessageBubble extends StatelessWidget { const SizedBox(height: 2), Text( contactData.phoneNumber!, - style: TextStyle( - color: ctx.dim, - fontSize: 12, - height: 1.2, - ), + style: TextStyle(color: ctx.dim, fontSize: 12, height: 1.2), ), ], ], @@ -1614,7 +1627,11 @@ class MessageBubble extends StatelessWidget { if (senderAvatar != null && senderAvatar.isNotEmpty) CircleAvatar( radius: 10, - backgroundImage: CachedNetworkImageProvider(senderAvatar, maxWidth: 96, maxHeight: 96), + backgroundImage: CachedNetworkImageProvider( + senderAvatar, + maxWidth: 96, + maxHeight: 96, + ), backgroundColor: ctx.cs.primaryContainer, ) else @@ -1816,13 +1833,10 @@ class MessageBubble extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ Text( - _formatTime(message.time), + formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)), style: TextStyle(color: ctx.dim, fontSize: 11), ), - if (isMe) ...[ - const SizedBox(width: 4), - _buildStatusIcon(ctx), - ], + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(ctx)], ], ), ); @@ -1840,7 +1854,7 @@ class MessageBubble extends StatelessWidget { borderRadius: BorderRadius.circular(4), ), child: Text( - _formatTime(message.time), + formatClock(DateTime.fromMillisecondsSinceEpoch(message.time)), style: const TextStyle( color: Colors.white, fontSize: 10, @@ -1880,13 +1894,6 @@ class MessageBubble extends StatelessWidget { return Icon(icon, size: 14, color: color); } - - String _formatTime(int timestamp) { - final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); - final hour = dt.hour.toString().padLeft(2, '0'); - final minute = dt.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; - } } class _VoiceMessageBubble extends StatefulWidget { @@ -1941,19 +1948,6 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { super.dispose(); } - String _formatDuration(int seconds) { - final min = seconds ~/ 60; - final sec = seconds % 60; - return '$min:${sec.toString().padLeft(2, '0')}'; - } - - String _formatTime(int timestamp) { - final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); - final hour = dt.hour.toString().padLeft(2, '0'); - final minute = dt.minute.toString().padLeft(2, '0'); - return '$hour:$minute'; - } - Widget _buildStatusIcon() { final status = widget.status; IconData icon; @@ -2050,16 +2044,17 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { ), child: ValueListenableBuilder( valueListenable: _progress, - builder: (context, progress, _) => FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: progress.clamp(0.0, 1.0), - child: Container( - decoration: BoxDecoration( - color: waveActiveColor, - borderRadius: BorderRadius.circular(2), + builder: (context, progress, _) => + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: waveActiveColor, + borderRadius: BorderRadius.circular(2), + ), + ), ), - ), - ), ), ), ); @@ -2103,7 +2098,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { width: 32, child: Center( child: Text( - _formatDuration(widget.duration), + formatSecondsMmSs(widget.duration), style: TextStyle( color: widget.textColor.withValues(alpha: 0.7), fontSize: 11, @@ -2133,7 +2128,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { ), if (!_transcriptionVisible) ...[ Text( - _formatTime(widget.time), + formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)), style: TextStyle( color: widget.textColor.withValues(alpha: 0.6), fontSize: 10, @@ -2151,7 +2146,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { mainAxisAlignment: MainAxisAlignment.end, children: [ Text( - _formatTime(widget.time), + formatClock(DateTime.fromMillisecondsSinceEpoch(widget.time)), style: TextStyle( color: widget.textColor.withValues(alpha: 0.6), fontSize: 10, diff --git a/lib/frontend/widgets/section_header.dart b/lib/frontend/widgets/section_header.dart new file mode 100644 index 0000000..8188d43 --- /dev/null +++ b/lib/frontend/widgets/section_header.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +/// Small primary-colored section title used across settings/profile screens. +class SectionHeader extends StatelessWidget { + final String title; + final EdgeInsetsGeometry padding; + final double fontSize; + + const SectionHeader( + this.title, { + super.key, + this.padding = const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), + this.fontSize = 13, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Padding( + padding: padding, + child: Text( + title, + style: TextStyle( + color: cs.primary, + fontSize: fontSize, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + } +} diff --git a/lib/frontend/widgets/sheet_helpers.dart b/lib/frontend/widgets/sheet_helpers.dart new file mode 100644 index 0000000..64909a0 --- /dev/null +++ b/lib/frontend/widgets/sheet_helpers.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +/// Standard rounded top shape for modal bottom sheets. +const RoundedRectangleBorder kSheetShape = RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), +); + +/// The little drag "grabber" pill shown at the top of a bottom sheet. +class SheetGrabber extends StatelessWidget { + final EdgeInsetsGeometry margin; + + const SheetGrabber({ + super.key, + this.margin = const EdgeInsets.symmetric(vertical: 10), + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + width: 40, + height: 4, + margin: margin, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), + ), + ); + } +} From 260e94632f0b2a8a4c3bc3f42b90a42b69954280 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 7 Jun 2026 14:00:20 +0300 Subject: [PATCH 194/323] fix(ios): drop private entitlements from pseudo-signed IPA --- .github/workflows/build-ios.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 29c898b..38db764 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -54,9 +54,7 @@ jobs: - platform-application get-task-allow - com.apple.private.security.no-container PLIST From 8a39862f8409c37875b4862badb3e545c45f5c28 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 7 Jun 2026 14:25:30 +0300 Subject: [PATCH 195/323] fix(chats): fall back to cached title/iconUrl for dialogs --- lib/frontend/screens/chats/chat_list_screen.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index c5117cd..5926bdc 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1429,8 +1429,9 @@ class _ChatListScreenState extends State break; } } - final name = ContactCache.get(secondId); - final avatar = ContactCache.getAvatar(secondId); + final name = ContactCache.get(secondId) ?? chat.title; + final avatar = + ContactCache.getAvatar(secondId) ?? chat.iconUrl; // ContactCache.isOfficial covers contacts loaded via opcode 32; // chat.isOfficial covers contacts from the login payload. final isVerified = From 3d234e686f2c7a0521753acfb528ee07776b200b Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 7 Jun 2026 14:29:31 +0300 Subject: [PATCH 196/323] change build version --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 3b6d708..b497787 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 0.5.0+10 environment: sdk: ^3.10.4 From 5eaa425c8f3b4fa2ef6be7908f9673dc5bc0059f Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 7 Jun 2026 18:27:48 +0000 Subject: [PATCH 197/323] =?UTF-8?q?feat(webapp):=20=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8-=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20webAppInitData,=20?= =?UTF-8?q?=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B2=20=D0=A1=D1=84=D0=B5=D1=80?= =?UTF-8?q?=D1=83=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/webapp.dart | 45 +++++ .../screens/profile/settings_tab.dart | 14 +- .../screens/webapp/web_app_screen.dart | 167 ++++++++++++++++++ lib/main.dart | 2 + pubspec.lock | 64 +++++++ pubspec.yaml | 1 + 6 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 lib/backend/modules/webapp.dart create mode 100644 lib/frontend/screens/webapp/web_app_screen.dart diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart new file mode 100644 index 0000000..b1307f5 --- /dev/null +++ b/lib/backend/modules/webapp.dart @@ -0,0 +1,45 @@ +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; + +class WebAppLaunch { + final String url; + + const WebAppLaunch({required this.url}); +} + +class WebAppModule { + static const int sferumBotId = 2340319; + + final Api _api; + + WebAppModule(this._api); + + Future fetchLaunch(int botId) async { + if (_api.state != SessionState.online) { + throw const WebAppUnavailable('Нет соединения с сервером'); + } + final packet = await _api.sendRequest(Opcode.webAppInitData, { + 'botId': botId, + }); + if (!packet.isOk) { + throw const WebAppUnavailable('Не удалось открыть мини-приложение'); + } + final data = packet.payload; + final url = (data is Map) ? data['url'] as String? : null; + if (url == null || url.isEmpty) { + throw const WebAppUnavailable('Сервер не вернул адрес приложения'); + } + return WebAppLaunch(url: url); + } + + Future fetchSferum() => fetchLaunch(sferumBotId); +} + +class WebAppUnavailable implements Exception { + final String message; + + const WebAppUnavailable(this.message); + + @override + String toString() => message; +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index dd43344..84ffc8e 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -15,6 +15,7 @@ import '../../widgets/komet_avatar.dart'; import '../../widgets/sheet_helpers.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; +import '../webapp/web_app_screen.dart'; import 'cloud_storage_screen.dart'; import 'customization_screen.dart'; import 'performance_screen.dart'; @@ -252,9 +253,20 @@ class _SettingsTabState extends State { icon: Symbols.badge, label: 'Цифровой ID', ), - const _SettingsItem( + _SettingsItem( icon: Symbols.language, label: 'Войти в Сферум', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => WebAppScreen( + title: 'Сферум', + loader: () => webAppModule.fetchSferum(), + ), + ), + ); + }, ), _SettingsItem( icon: Symbols.info, diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart new file mode 100644 index 0000000..7d17af9 --- /dev/null +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/webapp.dart'; + +class WebAppScreen extends StatefulWidget { + final String title; + final Future Function() loader; + + const WebAppScreen({ + super.key, + required this.title, + required this.loader, + }); + + @override + State createState() => _WebAppScreenState(); +} + +class _WebAppScreenState extends State { + InAppWebViewController? _controller; + WebAppLaunch? _launch; + String? _loadError; + double _progress = 0; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loadError = null; + _launch = null; + }); + try { + final launch = await widget.loader(); + if (!mounted) return; + setState(() => _launch = launch); + } catch (e) { + if (!mounted) return; + setState(() => _loadError = e.toString()); + } + } + + Future _handleBack() async { + final controller = _controller; + if (controller != null && await controller.canGoBack()) { + await controller.goBack(); + return false; + } + return true; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final navigator = Navigator.of(context); + if (await _handleBack()) navigator.pop(); + }, + child: Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + title: Text(widget.title), + leading: IconButton( + icon: const Icon(Symbols.close), + onPressed: () => Navigator.of(context).maybePop(), + ), + actions: [ + IconButton( + icon: const Icon(Symbols.refresh), + onPressed: _launch == null + ? null + : () => _controller?.reload(), + ), + ], + bottom: _progress > 0 && _progress < 1 + ? PreferredSize( + preferredSize: const Size.fromHeight(2), + child: LinearProgressIndicator( + value: _progress, + minHeight: 2, + backgroundColor: Colors.transparent, + ), + ) + : null, + ), + body: _buildBody(cs), + ), + ); + } + + Widget _buildBody(ColorScheme cs) { + if (_loadError != null) { + return _ErrorView(message: _loadError!, onRetry: _load); + } + final launch = _launch; + if (launch == null) { + return const Center(child: CircularProgressIndicator()); + } + return InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(launch.url)), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + domStorageEnabled: true, + thirdPartyCookiesEnabled: true, + supportZoom: false, + transparentBackground: true, + mediaPlaybackRequiresUserGesture: false, + useHybridComposition: true, + ), + onWebViewCreated: (controller) => _controller = controller, + onProgressChanged: (controller, progress) { + if (!mounted) return; + setState(() => _progress = progress / 100); + }, + onReceivedError: (controller, request, error) { + if (!mounted) return; + if (request.isForMainFrame ?? false) { + setState(() => _loadError = error.description); + } + }, + ); + } +} + +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: onRetry, + child: const Text('Повторить'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index a1b476e..5605d12 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,6 +30,7 @@ import 'backend/modules/contacts.dart'; import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; import 'backend/modules/polls.dart'; +import 'backend/modules/webapp.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; import 'core/transport/tls_config.dart'; @@ -47,6 +48,7 @@ final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); final pollsModule = PollsModule(api); +final webAppModule = WebAppModule(api); final fileUploader = FileUploader(api: api, messages: messagesModule); final RouteObserver> appRouteObserver = RouteObserver>(); diff --git a/pubspec.lock b/pubspec.lock index 9bf17e2..5bb08d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -326,6 +326,70 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" flutter_launcher_icons: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index b497787..adb1786 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: firebase_core: ^4.1.1 firebase_messaging: ^16.0.2 flutter_local_notifications: ^21.0.0 + flutter_inappwebview: ^6.1.5 dev_dependencies: flutter_test: From 5ecc74237e3c70e06a7e5f08f6926078279a8d1e Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 7 Jun 2026 19:06:44 +0000 Subject: [PATCH 198/323] =?UTF-8?q?fix(sferum):=20=D1=80=D0=B5=D0=B7=D0=BE?= =?UTF-8?q?=D0=BB=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B1=D0=BE=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=BE=D0=BC=20web-app=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/webapp.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart index b1307f5..db5d0c9 100644 --- a/lib/backend/modules/webapp.dart +++ b/lib/backend/modules/webapp.dart @@ -18,6 +18,7 @@ class WebAppModule { if (_api.state != SessionState.online) { throw const WebAppUnavailable('Нет соединения с сервером'); } + await _resolveBot(botId); final packet = await _api.sendRequest(Opcode.webAppInitData, { 'botId': botId, }); @@ -33,6 +34,14 @@ class WebAppModule { } Future fetchSferum() => fetchLaunch(sferumBotId); + + Future _resolveBot(int botId) async { + try { + await _api.sendRequest(Opcode.contactInfo, { + 'contactIds': [botId], + }); + } catch (_) {} + } } class WebAppUnavailable implements Exception { From b02152248c57901f495e1ce48f8f033d042262b0 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 7 Jun 2026 19:32:53 +0000 Subject: [PATCH 199/323] =?UTF-8?q?fix(webapp):=20=D0=B4=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20appid=20?= =?UTF-8?q?=D0=A1=D1=84=D0=B5=D1=80=D1=83=D0=BC=D0=B0=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D0=B0=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 26 ++++++++++++++++++++++++++ lib/backend/modules/webapp.dart | 30 ++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 88cde47..a4d0991 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -12,6 +12,7 @@ import 'chats.dart'; import 'contacts.dart'; import 'folders.dart'; import 'messages.dart'; +import 'webapp.dart'; String _normalizeAuthPhone(String phone) { final digits = phone.replaceAll(RegExp(r'\D'), ''); @@ -1281,6 +1282,9 @@ class AccountModule { final config = data['config'] as Map?; final serverConfig = config?['server'] as Map?; final userConfig = config?['user'] as Map?; + if (serverConfig != null) { + await _persistEntryBannerApps(accountId, serverConfig); + } final yMap = serverConfig?['y-map'] as Map?; final whiteListLinks = serverConfig?['white-list-links'] as List?; final fileUploadUnsupported = serverConfig?['file-upload-unsupported-types'] as List?; @@ -1305,6 +1309,28 @@ class AccountModule { await AppDatabase.saveLoginInfo(accountId, jsonEncode(info)); } + Future _persistEntryBannerApps(int accountId, Map serverConfig) async { + final banners = serverConfig['settings-entry-banners']; + if (banners is! List) return; + for (final banner in banners) { + final items = (banner is Map) ? banner['items'] : null; + if (items is! List) continue; + for (final item in items) { + if (item is! Map) continue; + final appId = item['appid']; + final icon = item['icon']?.toString().toLowerCase() ?? ''; + if (appId is int && icon.contains('sferum')) { + await AppDatabase.setSyncValue( + accountId, + EntryBannerApps.sferumKey, + appId.toString(), + ); + return; + } + } + } + } + Map _extractChatMarker(List chats) { int? latestTime; for (final chat in chats) { diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart index db5d0c9..f7b0563 100644 --- a/lib/backend/modules/webapp.dart +++ b/lib/backend/modules/webapp.dart @@ -1,5 +1,11 @@ import '../api.dart'; import '../../core/protocol/opcode_map.dart'; +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; + +abstract class EntryBannerApps { + static const String sferumKey = 'entry_banner_app_sferum'; +} class WebAppLaunch { final String url; @@ -8,8 +14,6 @@ class WebAppLaunch { } class WebAppModule { - static const int sferumBotId = 2340319; - final Api _api; WebAppModule(this._api); @@ -18,7 +22,6 @@ class WebAppModule { if (_api.state != SessionState.online) { throw const WebAppUnavailable('Нет соединения с сервером'); } - await _resolveBot(botId); final packet = await _api.sendRequest(Opcode.webAppInitData, { 'botId': botId, }); @@ -33,14 +36,21 @@ class WebAppModule { return WebAppLaunch(url: url); } - Future fetchSferum() => fetchLaunch(sferumBotId); + Future fetchSferum() async { + final botId = await _resolveEntryApp(EntryBannerApps.sferumKey); + if (botId == null) { + throw const WebAppUnavailable( + 'Сферум сейчас недоступен. Переподключитесь и попробуйте снова.', + ); + } + return fetchLaunch(botId); + } - Future _resolveBot(int botId) async { - try { - await _api.sendRequest(Opcode.contactInfo, { - 'contactIds': [botId], - }); - } catch (_) {} + Future _resolveEntryApp(String key) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return null; + final raw = await AppDatabase.getSyncValue(accountId, key); + return int.tryParse(raw ?? ''); } } From 7ad258f6c09182b7f9a2fd6fa23c535168b5720f Mon Sep 17 00:00:00 2001 From: Jganenok Date: Mon, 8 Jun 2026 16:20:46 +0700 Subject: [PATCH 200/323] =?UTF-8?q?fix:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B0=D0=BB=202FA,=20=D1=82=D0=B5=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D1=8C=20=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=82=D1=8C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 25 +- .../auth/code_confirmation_screen.dart | 14 +- .../profile/password_entry_screen.dart | 854 +++++++++--------- lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 4 + lib/l10n/app_localizations_ru.dart | 4 + lib/l10n/app_ru.arb | 1 + 8 files changed, 460 insertions(+), 449 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index a4d0991..57e6200 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -667,9 +667,10 @@ class AccountModule { required String trackId, required String password, String? hint, + bool withEmail = true, }) async { _ensureOnline(); - final capabilities = [0, if (hint != null) 3, 4]; + final capabilities = [0, if (hint != null) 3, if (withEmail) 4]; final payload = { 'expectedCapabilities': capabilities, 'trackId': trackId, @@ -727,7 +728,7 @@ class AccountModule { Future check2faPassword(String trackId, String password) async { _ensureOnline(); - final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, { + final packet = await _api.sendRequest(Opcode.authCheckPassword, { 'trackId': trackId, 'password': password, }); @@ -774,31 +775,15 @@ class AccountModule { ); } - Future update2faEmail({ - required String trackId, - required String email, - required String code, - }) async { + Future commit2faEmailChange(String trackId) async { _ensureOnline(); - final verifyPacket = await _api.sendRequest(Opcode.authVerifyEmail, { - 'trackId': trackId, - 'email': email, - }); - _checkPacketError(verifyPacket, 'update2faEmail: verify'); - - final codePacket = await _api.sendRequest(Opcode.authCheckEmail, { - 'trackId': trackId, - 'verifyCode': code, - }); - _checkPacketError(codePacket, 'update2faEmail: code'); - final payload = { 'expectedCapabilities': [4], 'trackId': trackId, }; return _processProfileUpdate( _api.sendRequest(Opcode.authSet2fa, payload), - 'update2faEmail', + 'commit2faEmailChange', ); } diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 5d09d5f..c8dbb99 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -405,8 +405,20 @@ class _CodeConfirmationScreenState extends State ), const Spacer(), Row( - mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, children: [ + Expanded( + child: Text( + l10n.codeConfirmation2faWarning, + style: TextStyle( + color: cs.error.withValues(alpha: 0.5), + fontSize: 13, + fontWeight: FontWeight.w400, + height: 1.35, + ), + ), + ), + const SizedBox(width: 16), FloatingActionButton( onPressed: () { if (_codeController.text.length == 6) _verifyCode(); diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index 12e95f2..0e8489d 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -3,7 +3,6 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show TwoFactorDetails; import '../../../core/storage/app_database.dart'; -import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; class PasswordEntryScreen extends StatefulWidget { @@ -16,6 +15,13 @@ class PasswordEntryScreen extends StatefulWidget { class _PasswordEntryScreenState extends State { bool _isLoading = true; bool _is2faEnabled = false; + bool _isAuthenticated = false; + String? _verifiedPassword; + TwoFactorDetails? _details; + + final _passwordController = TextEditingController(); + final ValueNotifier _isVerifying = ValueNotifier(false); + String? _errorMessage; @override void initState() { @@ -23,6 +29,34 @@ class _PasswordEntryScreenState extends State { _check2faStatus(); } + @override + void dispose() { + _passwordController.dispose(); + _isVerifying.dispose(); + super.dispose(); + } + + Future _authenticate() async { + if (_passwordController.text.isEmpty) return; + _isVerifying.value = true; + setState(() => _errorMessage = null); + try { + final trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(trackId, _passwordController.text); + final details = await accountModule.get2faDetails(trackId); + if (!mounted) return; + setState(() { + _isAuthenticated = true; + _verifiedPassword = _passwordController.text; + _details = details; + }); + } catch (_) { + if (mounted) setState(() => _errorMessage = 'Неверный пароль'); + } finally { + if (mounted) _isVerifying.value = false; + } + } + Future _check2faStatus() async { try { bool is2faEnabled; @@ -68,7 +102,7 @@ class _PasswordEntryScreenState extends State { SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildMainSection(cs), + child: _buildBody(cs), ), ), ], @@ -106,7 +140,13 @@ class _PasswordEntryScreenState extends State { ); } - Widget _buildMainSection(ColorScheme cs) { + Widget _buildBody(ColorScheme cs) { + if (!_is2faEnabled) return _buildSetupSection(cs); + if (!_isAuthenticated) return _buildPasswordGate(cs); + return _buildManageSection(cs); + } + + Widget _buildSetupSection(ColorScheme cs) { return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -114,81 +154,281 @@ class _PasswordEntryScreenState extends State { ), child: Column( children: [ - Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: cs.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - _is2faEnabled ? Symbols.lock : Symbols.lock_open, - color: cs.primary, - size: 24, + _buildHeaderTile( + cs, + icon: Symbols.lock_open, + title: 'Пароль не установлен', + subtitle: 'Двухфакторная аутентификация', + ), + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Symbols.settings, + label: 'Установить пароль', + isLast: true, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorSetupScreen(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildPasswordGate(ColorScheme cs) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Symbols.lock, color: cs.primary, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + 'Введите пароль для входа, чтобы управлять защитой', + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, ), ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _is2faEnabled - ? 'Пароль установлен' - : 'Пароль не установлен', - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, + ), + ], + ), + const SizedBox(height: 20), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + _PasswordField(controller: _passwordController, hintText: 'Пароль'), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ValueListenableBuilder( + valueListenable: _isVerifying, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _authenticate, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, ), + ) + : const Text('Продолжить'), + ), + ), + ), + ], + ), + ); + } + + Widget _buildManageSection(ColorScheme cs) { + return Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Symbols.lock, color: cs.primary, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Пароль установлен', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, ), + ), + if (_details?.email != null && + _details!.email!.isNotEmpty) ...[ const SizedBox(height: 4), Text( - _is2faEnabled - ? 'Используется для дополнительной защиты' - : 'Двухфакторная аутентификация', + _details!.email!, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, ), ), ], + if (_details?.hint != null && + _details!.hint!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Подсказка: ${_details!.hint}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + _buildActionRow( + cs, + icon: Symbols.password, + label: 'Изменить пароль', + isLast: false, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TwoFactorPasswordChangeScreen( + currentPassword: _verifiedPassword!, + ), ), ), + ), + Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + _buildActionRow( + cs, + icon: Icons.email_outlined, + label: 'Изменить почту', + isLast: false, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TwoFactorEmailChangeScreen( + currentPassword: _verifiedPassword!, + ), + ), + ), + ), + Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + _buildActionRow( + cs, + icon: Icons.delete_outline, + label: 'Удалить пароль', + isLast: true, + textColor: cs.error, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TwoFactorRemoveScreen( + currentPassword: _verifiedPassword!, + ), + ), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildHeaderTile( + ColorScheme cs, { + required IconData icon, + required String title, + required String subtitle, + }) { + return Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: cs.primary, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), ], ), ), - Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), - _buildActionRow( - cs, - icon: Symbols.settings, - label: _is2faEnabled ? 'Изменить пароль' : 'Установить пароль', - isLast: _is2faEnabled, - onTap: () => _navigateToPasswordSetup(context, cs), - ), - if (_is2faEnabled) ...[ - Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), - _buildActionRow( - cs, - icon: Icons.email_outlined, - label: 'Изменить почту', - isLast: false, - onTap: () => _navigateToEmailChange(context, cs), - ), - Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), - _buildActionRow( - cs, - icon: Icons.delete_outline, - label: 'Удалить пароль', - isLast: true, - textColor: cs.error, - onTap: () => _showRemoveConfirmation(context, cs), - ), - ], ], ), ); @@ -255,40 +495,6 @@ class _PasswordEntryScreenState extends State { ); } - void _navigateToPasswordSetup(BuildContext context, ColorScheme cs) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const TwoFactorSetupScreen()), - ); - } - - void _navigateToEmailChange(BuildContext context, ColorScheme cs) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const TwoFactorEmailChangeScreen(), - ), - ); - } - - Future _showRemoveConfirmation( - BuildContext context, - ColorScheme cs, - ) async { - final confirmed = await showConfirmDialog( - context, - title: 'Удалить пароль?', - message: - 'Вы уверены, что хотите удалить пароль для входа? Это ослабит защиту вашего аккаунта.', - confirmLabel: 'Удалить', - destructive: true, - ); - if (!confirmed || !context.mounted) return; - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const TwoFactorRemoveScreen()), - ); - } } class TwoFactorSetupScreen extends StatefulWidget { @@ -358,6 +564,10 @@ class _TwoFactorSetupScreenState extends State { setState(() => _step = 3); break; case 3: + if (_emailController.text.isEmpty) { + await _finishSetup(withEmail: false); + break; + } if (!_emailController.text.contains('@')) { setState(() => _errorMessage = 'Введите корректный email'); break; @@ -371,19 +581,7 @@ class _TwoFactorSetupScreenState extends State { break; } await accountModule.verify2faCode(_trackId!, _codeController.text); - await accountModule.confirm2fa( - trackId: _trackId!, - password: _passwordController.text, - hint: _hintController.text.isEmpty ? null : _hintController.text, - ); - if (mounted) { - showCustomNotification(context, 'Пароль установлен'); - Navigator.popUntil( - context, - (route) => - route.isFirst || route.settings.name == 'SecurityScreen', - ); - } + await _finishSetup(withEmail: true); break; } } catch (e) { @@ -395,6 +593,22 @@ class _TwoFactorSetupScreenState extends State { } } + Future _finishSetup({required bool withEmail}) async { + await accountModule.confirm2fa( + trackId: _trackId!, + password: _passwordController.text, + hint: _hintController.text.isEmpty ? null : _hintController.text, + withEmail: withEmail, + ); + if (mounted) { + showCustomNotification(context, 'Пароль установлен'); + Navigator.popUntil( + context, + (route) => route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -647,7 +861,7 @@ class _TwoFactorSetupScreenState extends State { ), const SizedBox(height: 8), Text( - 'Для восстановления пароля', + 'Для восстановления пароля. Необязательно', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 16), @@ -655,7 +869,7 @@ class _TwoFactorSetupScreenState extends State { controller: _emailController, keyboardType: TextInputType.emailAddress, decoration: InputDecoration( - hintText: 'example@mail.ru', + hintText: 'example@mail.ru (необязательно)', filled: true, fillColor: cs.surfaceContainerHighest, border: OutlineInputBorder( @@ -706,207 +920,10 @@ class _TwoFactorSetupScreenState extends State { } } -class TwoFactorManageScreen extends StatefulWidget { - const TwoFactorManageScreen({super.key}); - - @override - State createState() => _TwoFactorManageScreenState(); -} - -class _TwoFactorManageScreenState extends State { - final _passwordController = TextEditingController(); - final ValueNotifier _isLoading = ValueNotifier(false); - bool _isAuthenticated = false; - String? _trackId; - TwoFactorDetails? _details; - String? _errorMessage; - - @override - void dispose() { - _passwordController.dispose(); - _isLoading.dispose(); - super.dispose(); - } - - Future _authenticate() async { - _isLoading.value = true; - setState(() => _errorMessage = null); - - try { - _trackId = await accountModule.enter2faPanel(); - await accountModule.check2faPassword(_trackId!, _passwordController.text); - final details = await accountModule.get2faDetails(_trackId!); - setState(() { - _isAuthenticated = true; - _details = details; - }); - } catch (e) { - setState(() => _errorMessage = 'Неверный пароль'); - } finally { - if (mounted) _isLoading.value = false; - } - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: cs.surface, - appBar: AppBar( - backgroundColor: cs.surface, - elevation: 0, - leading: IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: () => Navigator.pop(context), - ), - title: Text( - 'Управление паролем', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w700, - ), - ), - ), - body: _isAuthenticated ? _buildManageContent(cs) : _buildAuthContent(cs), - ); - } - - Widget _buildAuthContent(ColorScheme cs) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Введите текущий пароль', - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), - if (_errorMessage != null) - Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: cs.errorContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - _errorMessage!, - style: TextStyle(color: cs.onErrorContainer), - ), - ), - _PasswordField(controller: _passwordController, hintText: 'Пароль'), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: ValueListenableBuilder( - valueListenable: _isLoading, - builder: (context, loading, _) => FilledButton( - onPressed: loading ? null : _authenticate, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: loading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : const Text('Продолжить'), - ), - ), - ), - ], - ), - ); - } - - Widget _buildManageContent(ColorScheme cs) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - ), - child: Column( - children: [ - Icon(Symbols.lock, color: cs.primary, size: 48), - const SizedBox(height: 12), - Text( - 'Пароль установлен', - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - ), - ), - if (_details?.email != null) ...[ - const SizedBox(height: 4), - Text( - _details!.email!, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), - ), - ], - if (_details?.hint != null && _details!.hint!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - 'Подсказка: ${_details!.hint}', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - ), - ], - ], - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const TwoFactorPasswordChangeScreen(), - ), - ); - }, - icon: const Icon(Symbols.edit), - label: const Text('Изменить пароль'), - style: OutlinedButton.styleFrom( - foregroundColor: cs.primary, - side: BorderSide(color: cs.outline), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - ], - ), - ); - } -} - class TwoFactorPasswordChangeScreen extends StatefulWidget { - const TwoFactorPasswordChangeScreen({super.key}); + final String currentPassword; + + const TwoFactorPasswordChangeScreen({super.key, required this.currentPassword}); @override State createState() => @@ -915,34 +932,40 @@ class TwoFactorPasswordChangeScreen extends StatefulWidget { class _TwoFactorPasswordChangeScreenState extends State { - final _passwordController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _confirmController = TextEditingController(); final _hintController = TextEditingController(); final ValueNotifier _isLoading = ValueNotifier(false); String? _errorMessage; @override void dispose() { - _passwordController.dispose(); + _newPasswordController.dispose(); + _confirmController.dispose(); _hintController.dispose(); _isLoading.dispose(); super.dispose(); } Future _changePassword() async { - if (_passwordController.text.length < 6) { + if (_newPasswordController.text.length < 6) { setState(() => _errorMessage = 'Пароль должен быть минимум 6 символов'); return; } + if (_confirmController.text != _newPasswordController.text) { + setState(() => _errorMessage = 'Пароли не совпадают'); + return; + } _isLoading.value = true; setState(() => _errorMessage = null); try { final trackId = await accountModule.enter2faPanel(); - await accountModule.check2faPassword(trackId, _passwordController.text); + await accountModule.check2faPassword(trackId, widget.currentPassword); await accountModule.update2faPassword( trackId: trackId, - newPassword: _passwordController.text, + newPassword: _newPasswordController.text, hint: _hintController.text.isEmpty ? null : _hintController.text, ); if (mounted) { @@ -985,15 +1008,6 @@ class _TwoFactorPasswordChangeScreenState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Новый пароль', - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), if (_errorMessage != null) Container( padding: const EdgeInsets.all(12), @@ -1007,10 +1021,24 @@ class _TwoFactorPasswordChangeScreenState style: TextStyle(color: cs.onErrorContainer), ), ), + Text( + 'Новый пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), _PasswordField( - controller: _passwordController, + controller: _newPasswordController, hintText: 'Введите новый пароль', ), + const SizedBox(height: 16), + _PasswordField( + controller: _confirmController, + hintText: 'Повторите новый пароль', + ), const SizedBox(height: 24), Text( 'Подсказка', @@ -1069,7 +1097,9 @@ class _TwoFactorPasswordChangeScreenState } class TwoFactorEmailChangeScreen extends StatefulWidget { - const TwoFactorEmailChangeScreen({super.key}); + final String currentPassword; + + const TwoFactorEmailChangeScreen({super.key, required this.currentPassword}); @override State createState() => @@ -1078,7 +1108,6 @@ class TwoFactorEmailChangeScreen extends StatefulWidget { class _TwoFactorEmailChangeScreenState extends State { - final _passwordController = TextEditingController(); final _emailController = TextEditingController(); final _codeController = TextEditingController(); int _step = 0; @@ -1088,13 +1117,20 @@ class _TwoFactorEmailChangeScreenState @override void dispose() { - _passwordController.dispose(); _emailController.dispose(); _codeController.dispose(); _isLoading.dispose(); super.dispose(); } + Future _ensureTrack() async { + if (_trackId != null) return _trackId!; + final trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(trackId, widget.currentPassword); + _trackId = trackId; + return trackId; + } + Future _nextStep() async { _isLoading.value = true; setState(() => _errorMessage = null); @@ -1102,32 +1138,21 @@ class _TwoFactorEmailChangeScreenState try { switch (_step) { case 0: - _trackId = await accountModule.enter2faPanel(); - await accountModule.check2faPassword( - _trackId!, - _passwordController.text, - ); - setState(() => _step = 1); - break; - case 1: if (!_emailController.text.contains('@')) { setState(() => _errorMessage = 'Введите корректный email'); break; } - await accountModule.verify2faEmail(_trackId!, _emailController.text); - setState(() => _step = 2); + final trackId = await _ensureTrack(); + await accountModule.verify2faEmail(trackId, _emailController.text); + setState(() => _step = 1); break; - case 2: + case 1: if (_codeController.text.length != 6) { setState(() => _errorMessage = 'Введите 6-значный код'); break; } await accountModule.verify2faCode(_trackId!, _codeController.text); - await accountModule.update2faEmail( - trackId: _trackId!, - email: _emailController.text, - code: _codeController.text, - ); + await accountModule.commit2faEmailChange(_trackId!); if (mounted) { showCustomNotification(context, 'Почта изменена'); Navigator.popUntil( @@ -1171,9 +1196,22 @@ class _TwoFactorEmailChangeScreenState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), if (_step == 0) ...[ Text( - 'Введите текущий пароль', + 'Новая почта', style: TextStyle( color: cs.onSurface, fontSize: 17, @@ -1181,78 +1219,49 @@ class _TwoFactorEmailChangeScreenState ), ), const SizedBox(height: 16), - _PasswordField( - controller: _passwordController, - hintText: 'Пароль', + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'example@mail.ru', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), ), ] else ...[ - if (_errorMessage != null) - Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: cs.errorContainer, + Text( + 'Введите код', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Код отправлен на ${_emailController.text}', + style: TextStyle(color: cs.onSurfaceVariant), + ), + const SizedBox(height: 16), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: '000000', + counterText: '', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - ), - child: Text( - _errorMessage!, - style: TextStyle(color: cs.onErrorContainer), + borderSide: BorderSide.none, ), ), - if (_step == 1) ...[ - Text( - 'Новая почта', - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), - TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: InputDecoration( - hintText: 'example@mail.ru', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), - ), - ] else ...[ - Text( - 'Введите код', - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - 'Код отправлен на ${_emailController.text}', - style: TextStyle(color: cs.onSurfaceVariant), - ), - const SizedBox(height: 16), - TextField( - controller: _codeController, - keyboardType: TextInputType.number, - maxLength: 6, - decoration: InputDecoration( - hintText: '000000', - counterText: '', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), - ), - ], + ), ], const SizedBox(height: 24), SizedBox( @@ -1278,7 +1287,7 @@ class _TwoFactorEmailChangeScreenState color: cs.onPrimary, ), ) - : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), + : Text(_step == 1 ? 'Сохранить' : 'Продолжить'), ), ), ), @@ -1290,20 +1299,20 @@ class _TwoFactorEmailChangeScreenState } class TwoFactorRemoveScreen extends StatefulWidget { - const TwoFactorRemoveScreen({super.key}); + final String currentPassword; + + const TwoFactorRemoveScreen({super.key, required this.currentPassword}); @override State createState() => _TwoFactorRemoveScreenState(); } class _TwoFactorRemoveScreenState extends State { - final _passwordController = TextEditingController(); final ValueNotifier _isLoading = ValueNotifier(false); String? _errorMessage; @override void dispose() { - _passwordController.dispose(); _isLoading.dispose(); super.dispose(); } @@ -1314,7 +1323,7 @@ class _TwoFactorRemoveScreenState extends State { try { final trackId = await accountModule.enter2faPanel(); - await accountModule.check2faPassword(trackId, _passwordController.text); + await accountModule.check2faPassword(trackId, widget.currentPassword); await accountModule.remove2fa(trackId); if (mounted) { showCustomNotification(context, 'Пароль удалён'); @@ -1376,15 +1385,6 @@ class _TwoFactorRemoveScreenState extends State { ), ), const SizedBox(height: 24), - Text( - 'Введите пароль для подтверждения', - style: TextStyle( - color: cs.onSurface, - fontSize: 17, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), if (_errorMessage != null) Container( padding: const EdgeInsets.all(12), @@ -1398,8 +1398,6 @@ class _TwoFactorRemoveScreenState extends State { style: TextStyle(color: cs.onErrorContainer), ), ), - _PasswordField(controller: _passwordController, hintText: 'Пароль'), - const SizedBox(height: 24), SizedBox( width: double.infinity, child: ValueListenableBuilder( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8c9a99f..501327c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -42,6 +42,7 @@ }, "codeResendSms": "Resend code via SMS", "codeError2faMissing": "Error: missing data for 2FA", + "codeConfirmation2faWarning": "MAX may require 2FA on your account to sign in. If you didn't receive the code, set up 2FA from a client where you're already signed in.", "proxySettingsTitle": "Proxy", "proxyTypeNone": "Disabled", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index eef4bee..9cc2ae6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -308,6 +308,12 @@ abstract class AppLocalizations { /// **'Error: missing data for 2FA'** String get codeError2faMissing; + /// No description provided for @codeConfirmation2faWarning. + /// + /// In en, this message translates to: + /// **'MAX may require 2FA on your account to sign in. If you didn\'t receive the code, set up 2FA from a client where you\'re already signed in.'** + String get codeConfirmation2faWarning; + /// No description provided for @proxySettingsTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8303e6b..4cd5ef1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -117,6 +117,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get codeError2faMissing => 'Error: missing data for 2FA'; + @override + String get codeConfirmation2faWarning => + 'MAX may require 2FA on your account to sign in. If you didn\'t receive the code, set up 2FA from a client where you\'re already signed in.'; + @override String get proxySettingsTitle => 'Proxy'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 20ab999..d9f7b1a 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -119,6 +119,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get codeError2faMissing => 'Ошибка: отсутствуют данные для 2FA'; + @override + String get codeConfirmation2faWarning => + 'MAX может требовать 2FA на вашем аккаунте для входа. Если вы не получили код — установите 2FA с клиента, на котором вы авторизованы.'; + @override String get proxySettingsTitle => 'Прокси'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ef69871..7cca1be 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -42,6 +42,7 @@ }, "codeResendSms": "Отправить код по SMS", "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA", + "codeConfirmation2faWarning": "MAX может требовать 2FA на вашем аккаунте для входа. Если вы не получили код — установите 2FA с клиента, на котором вы авторизованы.", "proxySettingsTitle": "Прокси", "proxyTypeNone": "Выключен", From 06b57206388c8f64295ac9999c665fd20a7d5fee Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 8 Jun 2026 18:23:23 +0300 Subject: [PATCH 201/323] =?UTF-8?q?feat(digital-id):=20=D0=A6=D0=B8=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BE=D0=B9=20ID=20=E2=80=94=20WebView-?= =?UTF-8?q?=D0=BC=D0=BE=D1=81=D1=82=20=D0=BA=20=D0=BE=D1=80=D0=B8=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB=D1=83=20+=20=D0=BD=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализован функционал Цифрового ID (паспорт, СНИЛС, ИНН, ОМС, водительское, СТС и др.) двумя путями с переключателем в меню разработчика. WebView-мост (по умолчанию): - Открывает оригинальную страницу digital-id.max.ru и реализует нативный хост-мост window.WebViewHandler (postEvent/receiveEvent), который настоящее приложение MAX предоставляет нативно: lifecycle-события, биометрия, SecureStorage/DeviceStorage, OpenLink (переход на Госуслуги) и заворот OAuth-callback (externalCallback) обратно в мини-апп. - botId Цифрового ID резолвится из settings-entry-banners конфига логина (как Сферум). Нативный режим: - DigitalIdModule — REST-клиент к ext-api.max.ru (все эндпоинты по образцу библиотеки gaijin: biometry, docs, esia, qr, verify, acms, profile), авторизация через #WebAppData= из webAppInitData. - Модели документов и экран онбординга/просмотра. Прочее: - Тумблер AppDigitalIdNative (WebView/нативно) и кнопка «Сбросить Цифровой ID» (очистка кук и данных WebView) — в настройках разработчика. --- lib/backend/modules/account.dart | 16 +- lib/backend/modules/digital_id.dart | 341 ++++++++++++ lib/backend/modules/webapp.dart | 16 + lib/core/config/app_digital_id_mode.dart | 18 + .../screens/digital_id/digital_id_screen.dart | 499 ++++++++++++++++++ .../digital_id/digital_id_web_screen.dart | 325 ++++++++++++ .../screens/profile/debug_menu_screen.dart | 126 +++++ .../screens/profile/settings_tab.dart | 15 +- lib/main.dart | 5 + lib/models/digital_id.dart | 271 ++++++++++ 10 files changed, 1624 insertions(+), 8 deletions(-) create mode 100644 lib/backend/modules/digital_id.dart create mode 100644 lib/core/config/app_digital_id_mode.dart create mode 100644 lib/frontend/screens/digital_id/digital_id_screen.dart create mode 100644 lib/frontend/screens/digital_id/digital_id_web_screen.dart create mode 100644 lib/models/digital_id.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 57e6200..333f0d9 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1297,23 +1297,25 @@ class AccountModule { Future _persistEntryBannerApps(int accountId, Map serverConfig) async { final banners = serverConfig['settings-entry-banners']; if (banners is! List) return; + final resolved = {}; for (final banner in banners) { final items = (banner is Map) ? banner['items'] : null; if (items is! List) continue; for (final item in items) { if (item is! Map) continue; final appId = item['appid']; + if (appId is! int) continue; final icon = item['icon']?.toString().toLowerCase() ?? ''; - if (appId is int && icon.contains('sferum')) { - await AppDatabase.setSyncValue( - accountId, - EntryBannerApps.sferumKey, - appId.toString(), - ); - return; + for (final entry in EntryBannerApps.iconMatchers.entries) { + if (!resolved.containsKey(entry.key) && icon.contains(entry.value)) { + resolved[entry.key] = appId; + } } } } + for (final entry in resolved.entries) { + await AppDatabase.setSyncValue(accountId, entry.key, entry.value.toString()); + } } Map _extractChatMarker(List chats) { diff --git a/lib/backend/modules/digital_id.dart b/lib/backend/modules/digital_id.dart new file mode 100644 index 0000000..19e4bb1 --- /dev/null +++ b/lib/backend/modules/digital_id.dart @@ -0,0 +1,341 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:device_info_plus/device_info_plus.dart'; + +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; +import '../../models/digital_id.dart'; +import 'webapp.dart'; + +class DigitalIdException implements Exception { + final String code; + final String message; + + const DigitalIdException(this.code, this.message); + + bool get isUnauthorized => code == 'UNAUTHORIZED'; + bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK'; + + @override + String toString() => message; +} + +class DigitalIdModule { + static const String _baseUrl = 'https://ext-api.max.ru'; + static const String _deviceIdKey = 'digital_id_device_id'; + static const String _tokenKey = 'digital_id_biometry_token'; + + final WebAppModule _webApp; + final HttpClient _http = HttpClient() + ..connectionTimeout = const Duration(seconds: 20); + + String? _webAppData; + String? _deviceId; + + DigitalIdModule(this._webApp); + + Future _ensureWebAppData({bool forceRefresh = false}) async { + if (!forceRefresh && _webAppData != null) return _webAppData!; + final launch = await _webApp.fetchDigitalId(); + final data = _extractWebAppData(launch.url); + if (data == null) { + throw const DigitalIdException( + 'NO_INIT_DATA', + 'Не удалось получить данные авторизации Цифрового ID', + ); + } + _webAppData = data; + return data; + } + + String? _extractWebAppData(String url) { + final hashIndex = url.indexOf('#'); + if (hashIndex < 0) return null; + final fragment = url.substring(hashIndex + 1); + final match = RegExp(r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)') + .firstMatch(fragment); + final raw = match?.group(1); + if (raw == null || raw.isEmpty) return null; + return Uri.decodeComponent(raw); + } + + Future deviceId() async { + if (_deviceId != null) return _deviceId!; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + final stored = await AppDatabase.getSyncValue(accountId, _deviceIdKey); + if (stored != null && stored.isNotEmpty) { + _deviceId = stored; + return stored; + } + } + final generated = await _generateDeviceId(); + if (accountId != null) { + await AppDatabase.setSyncValue(accountId, _deviceIdKey, generated); + } + _deviceId = generated; + return generated; + } + + Future _generateDeviceId() async { + final rnd = Random.secure(); + final bytes = List.generate(16, (_) => rnd.nextInt(256)); + final hex = + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + try { + final info = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final android = await info.androidInfo; + return '${android.id}_$hex'; + } + } catch (_) {} + return hex; + } + + Future _send( + String method, + String path, { + Object? body, + bool retry = true, + }) async { + final webAppData = await _ensureWebAppData(); + final uri = Uri.parse('$_baseUrl$path'); + final request = await _http.openUrl(method, uri); + request.headers.set('Authorization', '#WebAppData=$webAppData'); + request.headers.set('Origin', 'https://digital-id.max.ru'); + request.headers.set('Referer', 'https://digital-id.max.ru/'); + request.headers.set('x-requested-with', 'ru.oneme.app'); + request.headers.set('Accept', 'application/json'); + request.headers.set( + 'User-Agent', + 'Mozilla/5.0 (Linux; Android 16; Pixel 7 Pro) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Version/4.0 Chrome/148.0.0.0 Mobile Safari/537.36', + ); + if (body != null) { + request.headers.contentType = ContentType.json; + request.add(utf8.encode(jsonEncode(body))); + } + final response = await request.close(); + final text = await response.transform(utf8.decoder).join(); + + if (response.statusCode == 401 && retry) { + await _ensureWebAppData(forceRefresh: true); + return _send(method, path, body: body, retry: false); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _errorFor(response.statusCode, text); + } + if (text.isEmpty) return null; + return jsonDecode(text); + } + + DigitalIdException _errorFor(int statusCode, String body) { + String code = 'HTTP_$statusCode'; + String message = 'Ошибка Цифрового ID ($statusCode)'; + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final rawCode = decoded['code'] ?? decoded['error'] ?? decoded['status']; + if (rawCode is String && rawCode.isNotEmpty) code = rawCode; + final rawMessage = decoded['message'] ?? decoded['error_description']; + if (rawMessage is String && rawMessage.isNotEmpty) message = rawMessage; + } + } catch (_) {} + if (statusCode == 401) code = 'UNAUTHORIZED'; + return DigitalIdException(code, message); + } + + Map _unwrapData(dynamic decoded) { + if (decoded is Map && decoded['data'] is Map) { + return decoded['data'] as Map; + } + return decoded is Map ? decoded : const {}; + } + + Future biometryStatus() async { + final decoded = await _send('GET', '/v2/digital-id/biometry-status'); + return DigitalIdBiometryStatus.fromMap(_unwrapData(decoded)); + } + + Future createBiometryToken({ + required String deviceId, + String? photoHash, + }) async { + final decoded = await _send( + 'POST', + '/v3/digital-id/create-biometry-token', + body: { + 'device_id': deviceId, + 'photo_hash': ?photoHash, + }, + ); + return _unwrapData(decoded)['token'] as String? ?? ''; + } + + Future refreshUserDocs(String token) async { + final decoded = + await _send('POST', '/v3/digital-id/refresh-user-docs', body: { + 'token': token, + }); + return _unwrapData(decoded)['state'] as String? ?? ''; + } + + Future getUserDocs(String state) async { + final decoded = await _send('POST', '/v2/digital-id/get-user-docs', body: { + 'state': state, + }); + if (decoded is Map && decoded['status'] == 'done') { + final data = decoded['data']; + if (data is Map) return DigitalIdUserDocs.fromMap(data); + } + return null; + } + + Future createEsiaLink() async { + final decoded = await _send('GET', '/v2/digital-id/create-esia-link'); + return DigitalIdEsiaLink.fromMap(decoded is Map ? decoded : const {}); + } + + Future verifyPhoto({ + required String deviceId, + String? photoHash, + }) async { + final decoded = await _send('POST', '/digital-id-verify-photo', body: { + 'device_id': deviceId, + 'photo_hash': ?photoHash, + }); + final status = decoded is Map ? decoded['status'] as String? : null; + return DigitalIdVerification.fromValue(status); + } + + Future shadowMode(String deviceId) async { + try { + final decoded = await _send('POST', '/v3/digital-id/shadow-mode', body: { + 'device_id': deviceId, + }); + return _unwrapData(decoded)['shadow_mode'] == true; + } on DigitalIdException catch (e) { + if (e.code == 'HTTP_404') return false; + rethrow; + } + } + + Future updateGuHashes() async { + await _send('POST', '/v2/digital-id/update-gu-hashes', body: const {}); + } + + Future userQr(String token) async { + final decoded = await _send('POST', '/v3/digital-id/user-qr', body: { + 'token': token, + }); + return DigitalIdUniversalQr.fromMap(_unwrapData(decoded)); + } + + Future generateQr({ + required String photo, + required String token, + required DigitalIdQrType qrType, + String? kidAct, + }) async { + final decoded = await _send('POST', '/v3/digital-id/generate-qr', body: { + 'photo': photo, + 'token': token, + 'qr_type': qrType.code, + 'kid_act': ?kidAct, + }); + return DigitalIdQr.fromMap(_unwrapData(decoded)); + } + + Future> getCardsList({ + String? passStatus, + String? inn, + }) async { + final query = { + 'pass_status': ?passStatus, + 'inn': ?inn, + }; + final suffix = + query.isEmpty ? '' : '?${Uri(queryParameters: query).query}'; + final decoded = + await _send('GET', '/v2/digital-id/get-cards-list$suffix'); + final cards = _unwrapData(decoded)['acms_cards']; + if (cards is! List) return const []; + return cards + .whereType() + .map(DigitalIdAcmsCard.fromMap) + .toList(growable: false); + } + + Future activateAcms({required String id, required String inn}) async { + await _send('POST', '/v2/digital-id/activate-acms', body: { + 'id': id, + 'inn': inn, + 'pass_status': 'active', + }); + } + + Future createLiteProfile(String deviceId) async { + await _send('POST', '/v2/digital-id/create-lite-profile', body: { + 'device_id': deviceId, + }); + } + + Future deleteProfile() async { + await _send('DELETE', '/v3/digital-id/delete-profile'); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.setSyncValue(accountId, _tokenKey, ''); + } + } + + Future _storedToken() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return null; + final value = await AppDatabase.getSyncValue(accountId, _tokenKey); + return (value != null && value.isNotEmpty) ? value : null; + } + + Future _saveToken(String token) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.setSyncValue(accountId, _tokenKey, token); + } + } + + Future ensureBiometryToken({String? photoHash}) async { + final existing = await _storedToken(); + if (existing != null) return existing; + final id = await deviceId(); + final token = await createBiometryToken(deviceId: id, photoHash: photoHash); + if (token.isNotEmpty) await _saveToken(token); + return token; + } + + Future loadDocuments({ + bool createIfMissing = false, + int attempts = 5, + }) async { + var token = await _storedToken(); + if (token == null) { + if (!createIfMissing) return null; + final id = await deviceId(); + token = await createBiometryToken(deviceId: id); + if (token.isEmpty) return null; + await _saveToken(token); + } + final state = await refreshUserDocs(token); + if (state.isEmpty) return null; + for (var attempt = 0; attempt < attempts; attempt++) { + final docs = await getUserDocs(state); + if (docs != null) return docs; + await Future.delayed(const Duration(seconds: 2)); + } + return null; + } + + void reset() { + _webAppData = null; + } +} diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart index f7b0563..a78a20b 100644 --- a/lib/backend/modules/webapp.dart +++ b/lib/backend/modules/webapp.dart @@ -5,6 +5,12 @@ import '../../core/storage/token_storage.dart'; abstract class EntryBannerApps { static const String sferumKey = 'entry_banner_app_sferum'; + static const String digitalIdKey = 'entry_banner_app_digital_id'; + + static const Map iconMatchers = { + sferumKey: 'sferum', + digitalIdKey: 'digital', + }; } class WebAppLaunch { @@ -46,6 +52,16 @@ class WebAppModule { return fetchLaunch(botId); } + Future fetchDigitalId() async { + final botId = await _resolveEntryApp(EntryBannerApps.digitalIdKey); + if (botId == null) { + throw const WebAppUnavailable( + 'Цифровой ID сейчас недоступен. Переподключитесь и попробуйте снова.', + ); + } + return fetchLaunch(botId); + } + Future _resolveEntryApp(String key) async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return null; diff --git a/lib/core/config/app_digital_id_mode.dart b/lib/core/config/app_digital_id_mode.dart new file mode 100644 index 0000000..1b22a5a --- /dev/null +++ b/lib/core/config/app_digital_id_mode.dart @@ -0,0 +1,18 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppDigitalIdNative { + static const prefKey = 'app_digital_id_native'; + static final ValueNotifier current = ValueNotifier(false); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? false; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/frontend/screens/digital_id/digital_id_screen.dart b/lib/frontend/screens/digital_id/digital_id_screen.dart new file mode 100644 index 0000000..741e5df --- /dev/null +++ b/lib/frontend/screens/digital_id/digital_id_screen.dart @@ -0,0 +1,499 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/digital_id.dart'; +import '../../../backend/modules/webapp.dart'; +import '../../../main.dart' show digitalIdModule; +import '../../../models/digital_id.dart'; +import '../../widgets/custom_notification.dart'; +import '../webapp/web_app_screen.dart'; + +const Map _documentLabels = { + 'passport': 'Паспорт РФ', + 'oms': 'Полис ОМС', + 'inn': 'ИНН', + 'driver_license': 'Водительское удостоверение', + 'vehicle_sts': 'СТС', + 'snils': 'СНИЛС', + 'child_birth_cert': 'Свидетельство о рождении', + 'pension_cert': 'Пенсионное удостоверение', + 'disabled_cert': 'Справка об инвалидности', + 'large_family_cert': 'Удостоверение многодетной семьи', + 'student_ticket': 'Студенческий билет', + 'child_inn': 'ИНН ребёнка', + 'child_oms': 'Полис ОМС ребёнка', +}; + +class DigitalIdScreen extends StatefulWidget { + const DigitalIdScreen({super.key}); + + @override + State createState() => _DigitalIdScreenState(); +} + +class _DigitalIdScreenState extends State { + bool _loading = true; + bool _busy = false; + String? _error; + bool _needsGosuslugi = false; + DigitalIdUserDocs? _docs; + DigitalIdBiometryStatus? _biometry; + List _cards = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + _needsGosuslugi = false; + }); + try { + final biometry = await digitalIdModule.biometryStatus(); + DigitalIdUserDocs? docs; + try { + docs = await digitalIdModule.loadDocuments(); + } on DigitalIdException catch (e) { + if (e.isNoGosuslugiLink) { + if (mounted) setState(() => _needsGosuslugi = true); + } else { + rethrow; + } + } + final cards = await digitalIdModule.getCardsList(passStatus: 'active'); + if (!mounted) return; + setState(() { + _biometry = biometry; + _docs = docs; + _cards = cards; + _loading = false; + }); + } on DigitalIdException catch (e) { + if (!mounted) return; + setState(() { + _error = e.message; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + Future _linkGosuslugi() async { + if (_busy) return; + setState(() => _busy = true); + try { + final link = await digitalIdModule.createEsiaLink(); + if (!mounted) return; + if (link.url.isEmpty) { + showCustomNotification(context, 'Не удалось получить ссылку Госуслуг'); + return; + } + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => WebAppScreen( + title: 'Госуслуги', + loader: () async => WebAppLaunch(url: link.url), + ), + ), + ); + if (!mounted) return; + await _load(); + } on DigitalIdException catch (e) { + if (mounted) showCustomNotification(context, e.message); + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _loadDocsExplicit() async { + if (_busy) return; + setState(() => _busy = true); + try { + final docs = await digitalIdModule.loadDocuments(createIfMissing: true); + if (!mounted) return; + if (docs != null) { + setState(() => _docs = docs); + } else { + showCustomNotification( + context, + 'Документы пока недоступны. Попробуйте позже.', + ); + } + } on DigitalIdException catch (e) { + if (!mounted) return; + if (e.isNoGosuslugiLink) setState(() => _needsGosuslugi = true); + showCustomNotification(context, e.message); + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + title: const Text('Цифровой ID'), + leading: IconButton( + icon: const Icon(Symbols.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + actions: [ + IconButton( + icon: const Icon(Symbols.refresh), + onPressed: _loading ? null : _load, + ), + ], + ), + body: _buildBody(cs), + ); + } + + Widget _buildBody(ColorScheme cs) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return _ErrorView(message: _error!, onRetry: _load); + } + return RefreshIndicator( + onRefresh: _load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + if (_docs != null) ..._buildProfile(cs, _docs!), + if (_docs == null) _buildOnboarding(cs), + if (_cards.isNotEmpty) ..._buildCards(cs), + const SizedBox(height: 16), + _buildBiometryInfo(cs), + ], + ), + ); + } + + Widget _buildOnboarding(ColorScheme cs) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Symbols.badge, size: 40, color: cs.primary), + const SizedBox(height: 12), + Text( + 'Цифровой ID не настроен', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + _needsGosuslugi + ? 'Привяжите аккаунт Госуслуг, чтобы документы появились в Цифровом ID. Номер телефона в MAX должен совпадать с номером в профиле Госуслуг.' + : 'Привяжите Госуслуги, чтобы получить доступ к документам, или обновите страницу, если уже настраивали Цифровой ID.', + style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _busy ? null : _linkGosuslugi, + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Symbols.link), + label: const Text('Привязать Госуслуги'), + ), + const SizedBox(height: 4), + TextButton.icon( + onPressed: _busy ? null : _loadDocsExplicit, + icon: const Icon(Symbols.sync), + label: const Text('Загрузить документы'), + ), + ], + ), + ); + } + + List _buildProfile(ColorScheme cs, DigitalIdUserDocs docs) { + final profile = docs.profile; + return [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon(Symbols.verified_user, size: 36, color: cs.onPrimaryContainer), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.fullName.isEmpty ? 'Профиль Госуслуг' : profile.fullName, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: cs.onPrimaryContainer, + ), + ), + if (profile.birthDate != null) + Text( + 'Дата рождения: ${profile.birthDate}', + style: TextStyle( + fontSize: 13, + color: cs.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _buildInfoSection(cs, 'Личные данные', [ + if (profile.snils != null) ('СНИЛС', profile.snils!), + if (profile.inn != null) ('ИНН', profile.inn!), + if (profile.gender != null) ('Пол', profile.gender!), + if (profile.birthPlace != null) ('Место рождения', profile.birthPlace!), + if (profile.registrationAddress != null) + ('Адрес регистрации', profile.registrationAddress!.formatted), + ]), + if (profile.documents.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Документы', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + const SizedBox(height: 8), + ...profile.documents.map((doc) => _buildDocumentTile(cs, doc)), + ], + ]; + } + + Widget _buildInfoSection( + ColorScheme cs, + String title, + List<(String, String)> rows, + ) { + if (rows.isEmpty) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: cs.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + ...rows.map((row) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: Text( + row.$1, + style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant), + ), + ), + Expanded( + child: Text( + row.$2, + style: TextStyle(fontSize: 14, color: cs.onSurface), + ), + ), + ], + ), + )), + ], + ), + ); + } + + Widget _buildDocumentTile(ColorScheme cs, DigitalIdDocument doc) { + final label = _documentLabels[doc.type] ?? doc.type; + final subtitleParts = [ + if (doc.series != null) 'серия ${doc.series}', + if (doc.number != null) '№ ${doc.number}', + ]; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.description, color: cs.primary), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: cs.onSurface, + ), + ), + if (subtitleParts.isNotEmpty) + Text( + subtitleParts.join(', '), + style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ); + } + + List _buildCards(ColorScheme cs) { + return [ + const SizedBox(height: 16), + Text( + 'Пропуска', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + const SizedBox(height: 8), + ..._cards.map((card) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.badge, color: cs.primary), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.companyName, + style: TextStyle(fontSize: 15, color: cs.onSurface), + ), + Text( + 'ИНН ${card.inn}', + style: + TextStyle(fontSize: 13, color: cs.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + )), + ]; + } + + Widget _buildBiometryInfo(ColorScheme cs) { + final biometry = _biometry; + if (biometry == null) return const SizedBox.shrink(); + return Row( + children: [ + Icon( + biometry.hasBiometryToken ? Symbols.check_circle : Symbols.info, + size: 18, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + biometry.hasBiometryToken + ? 'Биометрия настроена на этом устройстве' + : 'Биометрия на этом устройстве не настроена', + style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), + ), + ), + ], + ); + } +} + +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: onRetry, + child: const Text('Повторить'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/digital_id/digital_id_web_screen.dart b/lib/frontend/screens/digital_id/digital_id_web_screen.dart new file mode 100644 index 0000000..cbd4756 --- /dev/null +++ b/lib/frontend/screens/digital_id/digital_id_web_screen.dart @@ -0,0 +1,325 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/webapp.dart'; +import '../../../main.dart' show webAppModule; + +Future resetDigitalIdWebData() async { + await CookieManager.instance().deleteAllCookies(); + try { + await WebStorageManager.instance().deleteAllData(); + } catch (_) {} +} + +const String _kBridge = r''' +(function(){ + var sawOpenLink = false; + function ssKey(k){ return 'komet_did_ss_' + k; } + function userId(){ + try { + var h = decodeURIComponent(decodeURIComponent(location.hash || '')); + var m = h.match(/"id"\s*:\s*(\d+)/); + if (m) return m[1]; + } catch(e){} + try { + var m2 = (location.hash || '').match(/id\W{1,8}?(\d{4,})/); + if (m2) return m2[1]; + } catch(e){} + return 'anon'; + } + try { + var uid = userId(); + if (localStorage.getItem('komet_did_owner') !== uid) { + try { localStorage.clear(); } catch(e){} + try { sessionStorage.clear(); } catch(e){} + try { + if (window.indexedDB && indexedDB.databases) { + indexedDB.databases().then(function(dbs){ + (dbs || []).forEach(function(db){ try { indexedDB.deleteDatabase(db.name); } catch(e){} }); + }); + } + } catch(e){} + localStorage.setItem('komet_did_owner', uid); + } + } catch(e){} + function reply(type, data){ + setTimeout(function(){ + try { window.WebApp.receiveEvent(type, data); } catch(e){} + }, 0); + } + function bioToken(){ + try { + var k = 'komet_did_bio_token'; + var v = localStorage.getItem(k); + if (!v) { + v = ''; + for (var i = 0; i < 32; i++) v += Math.floor(Math.random() * 16).toString(16); + localStorage.setItem(k, v); + } + return v; + } catch(e){ return 'komet-did-fallback-token'; } + } + function tokenSaved(){ + try { return !!localStorage.getItem('komet_did_bio_token'); } catch(e){ return false; } + } + function handle(type, dataStr){ + var data = {}; + try { data = JSON.parse(dataStr || '{}'); } catch(e){} + var requestId = data.requestId; + switch (type) { + case 'WebAppBiometryGetInfo': + reply(type, { + requestId: requestId, available: true, + access_requested: tokenSaved(), accessRequested: tokenSaved(), + access_granted: tokenSaved(), accessGranted: tokenSaved(), + token_saved: tokenSaved(), tokenSaved: tokenSaved(), + device_id: 'komet-device', deviceId: 'komet-device', + type: 'face', biometricType: 'face' + }); + return; + case 'WebAppBiometryRequestAccess': + reply(type, { requestId: requestId, granted: true, access_granted: true, accessGranted: true, status: 'granted' }); + return; + case 'WebAppBiometryAuthenticate': + reply(type, { requestId: requestId, token: bioToken(), success: true, status: 'authenticated' }); + return; + case 'WebAppBiometryUpdateToken': + case 'WebAppBiometryUpdateBiometricToken': + reply(type, { requestId: requestId, success: true, status: 'updated' }); + return; + case 'WebAppOpenLink': + sawOpenLink = true; + if (data && data.url) { + setTimeout(function(){ + try { window.location.assign(data.url); } catch(e){} + }, 0); + } + return; + case 'WebAppClose': + if (!sawOpenLink) { + try { window.flutter_inappwebview.callHandler('closeWebApp'); } catch(e){} + } + return; + default: + if (type.indexOf('SecureStorage') >= 0 || type.indexOf('DeviceStorage') >= 0) { + var key = data.key; + if (/Set|Save|Put/i.test(type)) { + try { localStorage.setItem(ssKey(key), JSON.stringify(data.value !== undefined ? data.value : null)); } catch(e){} + reply(type, { requestId: requestId, success: true }); + } else if (/Remove|Delete|Clear/i.test(type)) { + try { localStorage.removeItem(ssKey(key)); } catch(e){} + reply(type, { requestId: requestId, success: true }); + } else { + var val = null; + try { + var raw = localStorage.getItem(ssKey(key)); + val = (raw == null) ? null : JSON.parse(raw); + } catch(e){} + reply(type, { requestId: requestId, value: val, data: val }); + } + return; + } + if (requestId != null) reply(type, { requestId: requestId }); + } + } + try { + window.WebViewHandler = { + postEvent: function(type, dataStr){ + try { handle(type, dataStr); } catch(e){} + } + }; + } catch(e){} +})(); +'''; + +class DigitalIdWebScreen extends StatefulWidget { + const DigitalIdWebScreen({super.key}); + + @override + State createState() => _DigitalIdWebScreenState(); +} + +class _DigitalIdWebScreenState extends State { + InAppWebViewController? _controller; + WebAppLaunch? _launch; + String? _loadError; + double _progress = 0; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loadError = null; + _launch = null; + }); + try { + final launch = await webAppModule.fetchDigitalId(); + if (!mounted) return; + setState(() => _launch = launch); + } catch (e) { + if (!mounted) return; + setState(() => _loadError = e.toString()); + } + } + + Future _handleBack() async { + final controller = _controller; + if (controller != null && await controller.canGoBack()) { + await controller.goBack(); + return false; + } + return true; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final navigator = Navigator.of(context); + if (await _handleBack()) navigator.pop(); + }, + child: Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + title: const Text('Цифровой ID'), + leading: IconButton( + icon: const Icon(Symbols.close), + onPressed: () => Navigator.of(context).maybePop(), + ), + actions: [ + IconButton( + icon: const Icon(Symbols.refresh), + onPressed: _launch == null ? null : () => _controller?.reload(), + ), + ], + bottom: _progress > 0 && _progress < 1 + ? PreferredSize( + preferredSize: const Size.fromHeight(2), + child: LinearProgressIndicator( + value: _progress, + minHeight: 2, + backgroundColor: Colors.transparent, + ), + ) + : null, + ), + body: _buildBody(cs), + ), + ); + } + + Widget _buildBody(ColorScheme cs) { + if (_loadError != null) { + return _ErrorView(message: _loadError!, onRetry: _load); + } + final launch = _launch; + if (launch == null) { + return const Center(child: CircularProgressIndicator()); + } + return InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(launch.url)), + initialUserScripts: UnmodifiableListView([ + UserScript( + source: _kBridge, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + ), + ]), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + domStorageEnabled: true, + thirdPartyCookiesEnabled: true, + supportZoom: false, + transparentBackground: true, + mediaPlaybackRequiresUserGesture: false, + useHybridComposition: true, + useShouldOverrideUrlLoading: true, + ), + onWebViewCreated: (controller) { + _controller = controller; + controller.addJavaScriptHandler( + handlerName: 'closeWebApp', + callback: (args) { + if (mounted) Navigator.of(context).maybePop(); + return null; + }, + ); + }, + onPermissionRequest: (controller, request) async { + return PermissionResponse( + resources: request.resources, + action: PermissionResponseAction.GRANT, + ); + }, + shouldOverrideUrlLoading: (controller, action) async { + final uri = action.request.url; + final url = uri?.toString() ?? ''; + final scheme = uri?.scheme ?? ''; + final isCallback = url.contains('externalCallback'); + if (isCallback || (scheme != 'http' && scheme != 'https')) { + final launchUrl = _launch?.url ?? 'https://digital-id.max.ru'; + final hashIdx = launchUrl.indexOf('#'); + final base = hashIdx >= 0 ? launchUrl.substring(0, hashIdx) : launchUrl; + final frag = hashIdx >= 0 ? launchUrl.substring(hashIdx) : ''; + final query = uri?.query ?? ''; + final target = query.isEmpty ? launchUrl : '$base?$query$frag'; + controller.loadUrl(urlRequest: URLRequest(url: WebUri(target))); + return NavigationActionPolicy.CANCEL; + } + return NavigationActionPolicy.ALLOW; + }, + onProgressChanged: (controller, progress) { + if (!mounted) return; + setState(() => _progress = progress / 100); + }, + onReceivedError: (controller, request, error) { + if (!mounted) return; + if (request.isForMainFrame ?? false) { + setState(() => _loadError = error.description); + } + }, + ); + } +} + +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton(onPressed: onRetry, child: const Text('Повторить')), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 10ebc4b..92909a2 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -6,6 +6,7 @@ import '../../../backend/modules/chats.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_stories.dart'; +import '../../../core/config/app_digital_id_mode.dart'; import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/storage/app_database.dart'; @@ -18,6 +19,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/login_success_screen.dart'; import '../calls/call_screen.dart'; +import '../digital_id/digital_id_web_screen.dart'; class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); @@ -542,6 +544,130 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppDigitalIdNative.current, + builder: (context, native, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.badge, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Нативный Цифровой ID', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + native + ? 'Нативный экран (REST ext-api.max.ru)' + : 'Оригинальная страница в WebView', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: native, + onChanged: (v) { + AppDigitalIdNative.save(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () async { + await resetDigitalIdWebData(); + if (!context.mounted) return; + showCustomNotification( + context, + 'Цифровой ID сброшен — Госуслуги спросят вход заново', + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.restart_alt, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Сбросить Цифровой ID', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Очистить куки и данные WebView', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 84ffc8e..00ba4e1 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -15,6 +15,9 @@ import '../../widgets/komet_avatar.dart'; import '../../widgets/sheet_helpers.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; +import '../../../core/config/app_digital_id_mode.dart'; +import '../digital_id/digital_id_screen.dart'; +import '../digital_id/digital_id_web_screen.dart'; import '../webapp/web_app_screen.dart'; import 'cloud_storage_screen.dart'; import 'customization_screen.dart'; @@ -249,9 +252,19 @@ class _SettingsTabState extends State { context, cs, items: [ - const _SettingsItem( + _SettingsItem( icon: Symbols.badge, label: 'Цифровой ID', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AppDigitalIdNative.current.value + ? const DigitalIdScreen() + : const DigitalIdWebScreen(), + ), + ); + }, ), _SettingsItem( icon: Symbols.language, diff --git a/lib/main.dart b/lib/main.dart index 5605d12..6734b46 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'core/config/app_stories.dart'; import 'core/config/app_media_cache.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; +import 'core/config/app_digital_id_mode.dart'; import 'backend/modules/account.dart'; import 'backend/modules/chats.dart'; import 'backend/modules/contacts.dart'; @@ -31,6 +32,7 @@ import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; import 'backend/modules/polls.dart'; import 'backend/modules/webapp.dart'; +import 'backend/modules/digital_id.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; import 'core/transport/tls_config.dart'; @@ -49,6 +51,7 @@ final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); final pollsModule = PollsModule(api); final webAppModule = WebAppModule(api); +final digitalIdModule = DigitalIdModule(webAppModule); final fileUploader = FileUploader(api: api, messages: messagesModule); final RouteObserver> appRouteObserver = RouteObserver>(); @@ -92,6 +95,7 @@ void main() async { final pranksFuture = AppPranks.load(); final storiesFuture = AppStories.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); + final digitalIdNativeFuture = AppDigitalIdNative.load(); final packageInfo = await packageInfoFuture; if (packageInfo.packageName == 'ru.oneme.app') { @@ -124,6 +128,7 @@ void main() async { AppPranks.current.value = await pranksFuture; AppStories.current.value = await storiesFuture; AppMediaCacheLimit.current.value = await cacheLimitFuture; + AppDigitalIdNative.current.value = await digitalIdNativeFuture; runApp( KometApp( initialLocale: initialLocale, diff --git a/lib/models/digital_id.dart b/lib/models/digital_id.dart new file mode 100644 index 0000000..3385583 --- /dev/null +++ b/lib/models/digital_id.dart @@ -0,0 +1,271 @@ +enum DigitalIdVerification { + valid, + invalid, + photoCreated, + notFound, + deviceMismatch, + liteValid, + unknown; + + static DigitalIdVerification fromValue(String? value) { + switch (value) { + case 'valid': + return DigitalIdVerification.valid; + case 'invalid': + return DigitalIdVerification.invalid; + case 'photo_created': + return DigitalIdVerification.photoCreated; + case 'not_found': + return DigitalIdVerification.notFound; + case 'device_mismatch': + return DigitalIdVerification.deviceMismatch; + case 'lite_valid': + return DigitalIdVerification.liteValid; + default: + return DigitalIdVerification.unknown; + } + } +} + +enum DigitalIdQrType { + m1('M1', 'max_confirm_age'), + m2('M2', 'max_cert_large_family'), + m3('M3', 'max_student_ticket'), + m4('M4', 'max_sor'), + m5('M5', 'max_single_benefits'), + m7('M7', 'max_invalid_certificate'), + m8('M8', 'max_pension_certificate'), + m11('M11', 'max_identify_verification'); + + const DigitalIdQrType(this.code, this.alias); + + final String code; + final String alias; +} + +class DigitalIdAddress { + final String? address; + final String? flat; + final String? frame; + final String? house; + final String? zipCode; + + const DigitalIdAddress({ + this.address, + this.flat, + this.frame, + this.house, + this.zipCode, + }); + + factory DigitalIdAddress.fromMap(Map map) { + return DigitalIdAddress( + address: map['address'] as String?, + flat: map['flat'] as String?, + frame: map['frame'] as String?, + house: map['house'] as String?, + zipCode: map['zip_code'] as String?, + ); + } + + String get formatted { + final parts = [ + if (address != null && address!.isNotEmpty) address!, + if (house != null && house!.isNotEmpty) 'д. $house', + if (frame != null && frame!.isNotEmpty) 'к. $frame', + if (flat != null && flat!.isNotEmpty) 'кв. $flat', + ]; + return parts.join(', '); + } +} + +class DigitalIdBiometryStatus { + final bool hasBiometryToken; + final String? deviceId; + final bool hasPhotoHash; + + const DigitalIdBiometryStatus({ + required this.hasBiometryToken, + required this.deviceId, + required this.hasPhotoHash, + }); + + factory DigitalIdBiometryStatus.fromMap(Map map) { + return DigitalIdBiometryStatus( + hasBiometryToken: map['has_biometry_token'] == true, + deviceId: map['device_id'] as String?, + hasPhotoHash: map['has_photo_hash'] == true, + ); + } +} + +class DigitalIdDocument { + final String type; + final Map fields; + + const DigitalIdDocument({required this.type, required this.fields}); + + factory DigitalIdDocument.fromMap(Map map) { + final fields = {}; + for (final entry in map.entries) { + fields[entry.key.toString()] = entry.value; + } + return DigitalIdDocument( + type: (map['type'] as String?) ?? 'unknown', + fields: fields, + ); + } + + String? get firstName => fields['first_name'] as String?; + String? get lastName => fields['last_name'] as String?; + String? get middleName => fields['middle_name'] as String?; + String? get number => fields['number'] as String?; + String? get series => fields['series'] as String?; +} + +class DigitalIdProfile { + final String? firstName; + final String? lastName; + final String? middleName; + final String? birthDate; + final String? birthPlace; + final String? gender; + final String? snils; + final String? inn; + final DigitalIdAddress? registrationAddress; + final List documents; + + const DigitalIdProfile({ + this.firstName, + this.lastName, + this.middleName, + this.birthDate, + this.birthPlace, + this.gender, + this.snils, + this.inn, + this.registrationAddress, + this.documents = const [], + }); + + factory DigitalIdProfile.fromMap(Map map) { + final rawDocs = map['documents']; + final documents = []; + if (rawDocs is List) { + for (final doc in rawDocs) { + if (doc is Map) documents.add(DigitalIdDocument.fromMap(doc)); + } + } + final address = map['registration_address']; + return DigitalIdProfile( + firstName: map['first_name'] as String?, + lastName: map['last_name'] as String?, + middleName: map['middle_name'] as String?, + birthDate: map['birth_date'] as String?, + birthPlace: map['birth_place'] as String?, + gender: map['gender'] as String?, + snils: map['snils'] as String?, + inn: map['inn'] as String?, + registrationAddress: + address is Map ? DigitalIdAddress.fromMap(address) : null, + documents: documents, + ); + } + + String get fullName { + final parts = [ + if (lastName != null && lastName!.isNotEmpty) lastName!, + if (firstName != null && firstName!.isNotEmpty) firstName!, + if (middleName != null && middleName!.isNotEmpty) middleName!, + ]; + return parts.join(' '); + } +} + +class DigitalIdUserDocs { + final int userId; + final DigitalIdProfile profile; + + const DigitalIdUserDocs({required this.userId, required this.profile}); + + factory DigitalIdUserDocs.fromMap(Map map) { + final profile = map['digital_profile']; + return DigitalIdUserDocs( + userId: (map['user_id'] as num?)?.toInt() ?? 0, + profile: profile is Map + ? DigitalIdProfile.fromMap(profile) + : const DigitalIdProfile(), + ); + } +} + +class DigitalIdEsiaLink { + final String? state; + final String url; + + const DigitalIdEsiaLink({this.state, required this.url}); + + factory DigitalIdEsiaLink.fromMap(Map map) { + return DigitalIdEsiaLink( + state: map['state'] as String?, + url: map['url'] as String? ?? '', + ); + } +} + +class DigitalIdQr { + final String qr; + final String? qrGost; + + const DigitalIdQr({required this.qr, this.qrGost}); + + factory DigitalIdQr.fromMap(Map map) { + return DigitalIdQr( + qr: map['qr'] as String? ?? '', + qrGost: map['qr_gost'] as String?, + ); + } +} + +class DigitalIdUniversalQr { + final String uidHash; + final String? phone; + final String? sessionId; + + const DigitalIdUniversalQr({ + required this.uidHash, + this.phone, + this.sessionId, + }); + + factory DigitalIdUniversalQr.fromMap(Map map) { + return DigitalIdUniversalQr( + uidHash: map['uid_hash'] as String? ?? '', + phone: map['phone'] as String?, + sessionId: map['session_id'] as String?, + ); + } +} + +class DigitalIdAcmsCard { + final String id; + final String inn; + final String companyName; + final String logoImg; + + const DigitalIdAcmsCard({ + required this.id, + required this.inn, + required this.companyName, + required this.logoImg, + }); + + factory DigitalIdAcmsCard.fromMap(Map map) { + return DigitalIdAcmsCard( + id: map['id'] as String? ?? '', + inn: map['inn'] as String? ?? '', + companyName: map['company_name'] as String? ?? '', + logoImg: map['logo_img'] as String? ?? '', + ); + } +} From a630f811802c366017f67e5bbc1f1e6317be2940 Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 8 Jun 2026 17:30:41 +0000 Subject: [PATCH 202/323] =?UTF-8?q?feat(ci):=20=D1=81=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20unsigned=20ipa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-ios.yml | 15 +++++++++++++++ .github/workflows/release-dev.yml | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 38db764..f85d17d 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -41,6 +41,21 @@ jobs: - name: Build iOS (no codesign) run: flutter build ios --release --no-codesign + - name: Package unsigned IPA + run: | + mkdir -p dist build/ios/Payload + cp -R build/ios/iphoneos/Runner.app build/ios/Payload/ + (cd build/ios && zip -qr ../../dist/Komet-ios-unsigned.ipa Payload) + rm -rf build/ios/Payload + + - name: Upload unsigned iOS artifact + uses: actions/upload-artifact@v4 + with: + name: komet-ios-unsigned + path: dist/Komet-ios-unsigned.ipa + if-no-files-found: error + retention-days: 30 + - name: Install ldid run: brew install ldid diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 6ac2d2c..a5de1e7 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -180,6 +180,12 @@ jobs: run: flutter pub get - name: Build iOS (no codesign) run: flutter build ios --release --no-codesign + - name: Package unsigned IPA + run: | + mkdir -p dist build/ios/Payload + cp -R build/ios/iphoneos/Runner.app build/ios/Payload/ + (cd build/ios && zip -qr ../../dist/Komet-ios-unsigned.ipa Payload) + rm -rf build/ios/Payload - name: Install ldid run: brew install ldid - name: Pseudo-sign with ldid From 7245f8bfb32fc3858f94ff929fea27b9a429e3b4 Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 8 Jun 2026 17:32:50 +0000 Subject: [PATCH 203/323] =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BD=D1=8F=D0=BB=20?= =?UTF-8?q?build=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index adb1786..a960e8d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.0+10 +version: 0.5.0+11 environment: sdk: ^3.10.4 From e587ef184bb6ea839f6bbf0b1c0e1ad3f7dc0f34 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Tue, 9 Jun 2026 00:57:20 +0700 Subject: [PATCH 204/323] =?UTF-8?q?fix:=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=84=D0=B8=D0=BA=D1=81=20=D0=BD=D0=B5=D0=B4?= =?UTF-8?q?=D0=BE=D1=87=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=20=D0=BF=D0=B0=D1=80=D1=8B=20=D1=8F=D0=B7=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Runner/Info.plist | 8 +- lib/backend/api.dart | 10 +- lib/backend/modules/account.dart | 7 +- lib/backend/modules/chats.dart | 11 +- lib/backend/modules/digital_id.dart | 16 ++- lib/backend/modules/file_uploader.dart | 12 +- lib/backend/modules/messages.dart | 14 +- lib/core/protocol/packet.dart | 127 +++++++++-------- lib/core/storage/app_database.dart | 18 ++- lib/core/storage/token_storage.dart | 36 ++++- lib/core/transport/connection.dart | 14 +- lib/core/transport/dispatcher.dart | 10 +- lib/core/transport/sender.dart | 2 +- lib/core/transport/tls_config.dart | 2 + lib/core/utils/log_redact.dart | 19 ++- lib/core/utils/webview_support.dart | 6 + lib/frontend/screens/auth/login_screen.dart | 2 + .../screens/chats/chat_list_screen.dart | 3 + lib/frontend/screens/chats/chat_screen.dart | 117 ++++++++-------- .../screens/chats/create_group_flow.dart | 15 +- .../screens/contacts/contacts_tab.dart | 6 +- .../screens/digital_id/digital_id_screen.dart | 130 +++++++++++------- .../digital_id/digital_id_web_screen.dart | 18 ++- lib/frontend/screens/profile/info_screen.dart | 3 +- .../profile/password_entry_screen.dart | 82 +++++++---- .../screens/profile/settings_tab.dart | 6 +- .../screens/webapp/web_app_screen.dart | 3 + lib/frontend/widgets/custom_notification.dart | 2 +- lib/frontend/widgets/message_bubble.dart | 22 ++- .../widgets/webview_permission_prompt.dart | 56 ++++++++ pubspec.lock | 56 ++++++++ pubspec.yaml | 1 + 32 files changed, 590 insertions(+), 244 deletions(-) create mode 100644 lib/core/utils/webview_support.dart create mode 100644 lib/frontend/widgets/webview_permission_prompt.dart diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bac0a92..02b5d74 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -61,6 +61,12 @@ UIApplicationSupportsIndirectInputEvents NSCameraUsageDescription - Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере. + Камера нужна для съёмки фото и видео в чатах, сканирования QR-кода входа и работы веб-приложений. + NSMicrophoneUsageDescription + Микрофон нужен для записи голосовых сообщений, видео и звонков. + NSPhotoLibraryUsageDescription + Доступ к галерее нужен, чтобы отправлять фото и видео в чатах. + NSPhotoLibraryAddUsageDescription + Доступ к галерее нужен, чтобы сохранять полученные фото и видео. diff --git a/lib/backend/api.dart b/lib/backend/api.dart index a3fb613..43234f8 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -301,15 +301,15 @@ class Api { /// Стрим всех входящих пушей от сервера. Stream get pushStream => _dispatcher.pushStream; - void dispose() { + Future dispose() async { _autoReconnect = false; _reconnectTimer?.cancel(); _cleanup(); _dispatcher.dispose(); - _connection.dispose(); - _stateController.close(); - _sessionExpiredController.close(); - _handshakeSuccessController.close(); + await _connection.dispose(); + await _stateController.close(); + await _sessionExpiredController.close(); + await _handshakeSuccessController.close(); } // Внутрянка diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 333f0d9..a71307d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -19,6 +19,11 @@ String _normalizeAuthPhone(String phone) { return '+$digits'; } +String _maskPhone(String phone) { + if (phone.length <= 5) return '***'; + return '${phone.substring(0, 3)}***${phone.substring(phone.length - 2)}'; +} + class PrivacyConfig { final String searchByPhone; final String incomingCall; @@ -1408,7 +1413,7 @@ class AccountModule { 'language': language, }; - logger.i('Запрос OTP-кода: phone=$normalizedPhone type=${type.value}'); + logger.i('Запрос OTP-кода: phone=${_maskPhone(normalizedPhone)} type=${type.value}'); final packet = await _api.sendRequest(Opcode.authRequest, payload); diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index bcc1a7a..0699017 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -191,6 +191,7 @@ class ChatsModule { static StreamSubscription? _globalPushSub; static StreamSubscription? _globalStateSub; + static Future _pushQueue = Future.value(); static final Set _dirtyChats = {}; static final Set _knownChats = {}; @@ -203,7 +204,7 @@ class ChatsModule { static void attachGlobalPushHandlers(Api api) { _globalPushSub?.cancel(); _globalStateSub?.cancel(); - _globalPushSub = api.pushStream.listen(_handleGlobalPush); + _globalPushSub = api.pushStream.listen(_enqueueGlobalPush); _globalStateSub = api.stateStream.listen(_handleSessionState); if (api.state != SessionState.online) { _markAllKnownChatsDirty(); @@ -240,6 +241,14 @@ class ChatsModule { _dirtyChats.addAll(_knownChats); } + static void _enqueueGlobalPush(Packet packet) { + _pushQueue = _pushQueue + .then((_) => _handleGlobalPush(packet)) + .catchError((Object e) { + logger.w('Ошибка обработки пуша: $e'); + }); + } + static Future _handleGlobalPush(Packet packet) async { switch (packet.opcode) { case Opcode.notifMessage: diff --git a/lib/backend/modules/digital_id.dart b/lib/backend/modules/digital_id.dart index 19e4bb1..03a66ec 100644 --- a/lib/backend/modules/digital_id.dart +++ b/lib/backend/modules/digital_id.dart @@ -286,6 +286,7 @@ class DigitalIdModule { await _send('DELETE', '/v3/digital-id/delete-profile'); final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { + await TokenStorage.deleteSecure('${_tokenKey}_$accountId'); await AppDatabase.setSyncValue(accountId, _tokenKey, ''); } } @@ -293,14 +294,23 @@ class DigitalIdModule { Future _storedToken() async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return null; - final value = await AppDatabase.getSyncValue(accountId, _tokenKey); - return (value != null && value.isNotEmpty) ? value : null; + final secureKey = '${_tokenKey}_$accountId'; + final secured = await TokenStorage.readSecure(secureKey); + if (secured != null && secured.isNotEmpty) return secured; + + final legacy = await AppDatabase.getSyncValue(accountId, _tokenKey); + if (legacy != null && legacy.isNotEmpty) { + await TokenStorage.writeSecure(secureKey, legacy); + await AppDatabase.setSyncValue(accountId, _tokenKey, ''); + return legacy; + } + return null; } Future _saveToken(String token) async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { - await AppDatabase.setSyncValue(accountId, _tokenKey, token); + await TokenStorage.writeSecure('${_tokenKey}_$accountId', token); } } diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 3091e60..3dc5342 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -7,6 +7,7 @@ import '../api.dart'; import '../../core/config/proxy_config.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/transport/proxy_connector.dart'; +import '../../core/transport/tls_config.dart'; import '../../core/utils/logger.dart'; import 'messages.dart'; @@ -158,11 +159,12 @@ class FileUploader { ? await ProxyConnector(proxySettings).connect(uri.host, uri.port) : await Socket.connect(uri.host, uri.port); if (uri.scheme != 'https') return base; - return SecureSocket.secure( - base, - host: uri.host, - onBadCertificate: (_) => true, - ); + final allowInsecure = await TlsConfig.isInsecureAllowed(); + if (allowInsecure) { + logger.w('TLS: проверка сертификата отключена (дебаг) — загрузка уязвима к MitM'); + return SecureSocket.secure(base, host: uri.host, onBadCertificate: (_) => true); + } + return SecureSocket.secure(base, host: uri.host); } void _writeHeaders( diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e893153..bbca3cb 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -250,6 +250,18 @@ class CachedMessage { ); } + static List _decodeRows(List> rows) => + rows.map(CachedMessage.fromDbRow).toList(); + + static Future> fromDbRowsAsync( + List> rows, + ) { + if (rows.length < 20) { + return Future.value(_decodeRows(rows)); + } + return compute(_decodeRows, rows); + } + Map toDbRow() => { 'id': id, 'account_id': accountId, @@ -361,7 +373,7 @@ class MessagesModule { limit: limit, offset: offset, ); - return rows.map(CachedMessage.fromDbRow).toList(); + return CachedMessage.fromDbRowsAsync(rows); } CachedMessage? _parseMessage( diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 448a866..5e16093 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -119,60 +119,57 @@ Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { return out; } -/// Распаковка пакета от сервера +const int _isolateDecodeThreshold = 4096; + Future unpackPacket(Uint8List packet) async { - return Isolate.run(() { - // Для удобства расшифровки пакета переводим в ByteData - ByteData packetData = ByteData.view( - packet.buffer, - packet.offsetInBytes, - packet.lengthInBytes, - ); + final header = ByteData.sublistView(packet); - // API версия и cmd представляют из себя 8 битные числа - final apiVer = packetData.getUint8(0) & 0xFF; - final cmd = packetData.getUint8(1) & 0xFF; + final apiVer = header.getUint8(0) & 0xFF; + final cmd = header.getUint8(1) & 0xFF; + final seq = header.getUint16(2) & 0xFFFF; + final opcode = header.getUint16(4) & 0xFFFF; + final packedLen = header.getUint32(6); + final compFlag = packedLen >> 24; + final payloadLength = packedLen & 0xFFFFFF; - // Sequence и OPCode представляют из себя 16 битные числа - final seq = packetData.getUint16(2) & 0xFFFF; - final opcode = packetData.getUint16(4) & 0xFFFF; + if (payloadLength == 0) { + return Packet(api: apiVer, cmd: cmd, seq: seq, opcode: opcode); + } - // После базовых переменных идет длина пакета, является 32 битным числом - final packedLen = packetData.getUint32(6); + final end = headerSize + payloadLength; + if (end > packet.length) { + throw Exception('Packet payload length $payloadLength exceeds buffer'); + } + final slice = Uint8List.sublistView(packet, headerSize, end); - // Compression flag показывает, сжат ли payload - final compFlag = packedLen >> 24; + dynamic payload; + if (compFlag == 0 && slice.length < _isolateDecodeThreshold) { + payload = _deserializePayload(slice, compFlag); + } else { + final owned = Uint8List.fromList(slice); + payload = await Isolate.run(() => _deserializePayload(owned, compFlag)); + } - // Длина payload'а - final payloadLength = packedLen & 0xFFFFFF; + return Packet( + api: apiVer, + cmd: cmd, + seq: seq, + opcode: opcode, + payload: payload, + ); +} - // Байты payload'а, могут быть сжаты LZ4 - var payloadBytes = packet.buffer.asUint8List(10, payloadLength); - - dynamic payload; - - if (payloadBytes.isNotEmpty) { - if (compFlag != 0) { - payloadBytes = _decompressPayload(payloadBytes); - } - - try { - payload = msgpack.deserialize(payloadBytes); - } catch (e) { - if (payloadBytes.isNotEmpty) { - throw Exception("MsgPack deserialization error: $e"); - } - } - } - - return Packet( - api: apiVer, - cmd: cmd, - seq: seq, - opcode: opcode, - payload: payload, - ); - }); +dynamic _deserializePayload(Uint8List payloadBytes, int compFlag) { + var bytes = payloadBytes; + if (compFlag != 0) { + bytes = _decompressPayload(bytes); + } + if (bytes.isEmpty) return null; + try { + return msgpack.deserialize(bytes); + } catch (e) { + throw Exception('MsgPack deserialization error: $e'); + } } /// Определяет формат сжатия по magic-number и распаковывает payload. @@ -185,7 +182,7 @@ Uint8List _decompressPayload(Uint8List src) { src[2] == 0x2F && src[3] == 0xFD) { try { - return ZstdCodec().decompress(src); + return ZstdCodec(maxDecompressedSize: _maxDecompressedSize).decompress(src); } catch (e) { throw Exception('Zstd decompression error: $e'); } @@ -215,9 +212,23 @@ Uint8List _decompressPayload(Uint8List src) { /// LZ4 block декомпрессия (без frame-заголовка). /// Сервер шлёт именно block-формат, dart_lz4 его не поддерживает. Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { - final dst = BytesBuilder(copy: false); + var out = Uint8List(1024); + int outLen = 0; int pos = 0; + void ensure(int extra) { + if (outLen + extra > maxSize) throw StateError('LZ4: превышен лимит'); + if (outLen + extra <= out.length) return; + var newCap = out.length * 2; + while (newCap < outLen + extra) { + newCap *= 2; + } + if (newCap > maxSize) newCap = maxSize; + final grown = Uint8List(newCap); + grown.setRange(0, outLen, out); + out = grown; + } + while (pos < src.length) { final token = src[pos++]; var litLen = token >> 4; @@ -231,7 +242,9 @@ Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { } if (litLen > 0) { - dst.add(src.sublist(pos, pos + litLen)); + ensure(litLen); + out.setRange(outLen, outLen + litLen, src, pos); + outLen += litLen; pos += litLen; } @@ -251,12 +264,14 @@ Uint8List _lz4BlockDecompress(Uint8List src, int maxSize) { } } - final out = dst.toBytes(); - final start = out.length - offset; - dst.add(List.generate(matchLen, (i) => out[start + (i % offset)])); - - if (dst.length > maxSize) throw StateError('LZ4: превышен лимит'); + ensure(matchLen); + final start = outLen - offset; + if (start < 0) throw StateError('LZ4: offset за пределами вывода'); + for (var i = 0; i < matchLen; i++) { + out[outLen + i] = out[start + i]; + } + outLen += matchLen; } - return dst.toBytes(); + return Uint8List.sublistView(out, 0, outLen); } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index f1cccfa..93af2bb 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -159,7 +159,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 10, + version: 11, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -209,6 +209,9 @@ class AppDatabase { 'ALTER TABLE chats_cache ADD COLUMN admins TEXT', ); } + if (oldVersion < 11) { + await _createIndexes(db); + } }, ); } @@ -234,6 +237,19 @@ class AppDatabase { await db.execute(_chatsCacheSchema); await db.execute(_contactsSchema); await db.execute(_messagesSchema); + await _createIndexes(db); + } + + static Future _createIndexes(Database db) async { + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(account_id, chat_id, time DESC)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_chats_account ON chats_cache(account_id, last_event_time DESC)', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_contacts_account ON contacts(account_id)', + ); } static const _contactsSchema = ''' diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 2d4f600..64002a7 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -1,22 +1,50 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; class TokenStorage { static const _tokenPrefix = 'auth_token_'; static const _activeAccountKey = 'active_account_id'; + static const _secure = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + + static Future writeSecure(String key, String value) async { + await _secure.write(key: key, value: value); + } + + static Future readSecure(String key) async { + return _secure.read(key: key); + } + + static Future deleteSecure(String key) async { + await _secure.delete(key: key); + } + static Future saveToken(String token, int accountId) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('$_tokenPrefix$accountId', token); + await _secure.write(key: '$_tokenPrefix$accountId', value: token); } static Future readToken(int accountId) async { + final key = '$_tokenPrefix$accountId'; + final secured = await _secure.read(key: key); + if (secured != null) return secured; + final prefs = await SharedPreferences.getInstance(); - return prefs.getString('$_tokenPrefix$accountId'); + final legacy = prefs.getString(key); + if (legacy != null) { + await _secure.write(key: key, value: legacy); + await prefs.remove(key); + return legacy; + } + return null; } static Future deleteToken(int accountId) async { + final key = '$_tokenPrefix$accountId'; + await _secure.delete(key: key); final prefs = await SharedPreferences.getInstance(); - await prefs.remove('$_tokenPrefix$accountId'); + await prefs.remove(key); } static Future setActiveAccount(int accountId) async { diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index afb9dd9..3b38410 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -30,7 +30,7 @@ class Connection { void _setState(SocketState newState) { if (_state == newState) return; _state = newState; - _stateController.add(newState); + if (!_stateController.isClosed) _stateController.add(newState); } Future connect( @@ -65,7 +65,9 @@ class Connection { logger.i('Подключено к $host:$port'); _subscription = _socket!.listen( - (data) => _dataController.add(data), + (data) { + if (!_dataController.isClosed) _dataController.add(data); + }, onError: (Object error) { logger.e('Ошибка сокета: $error'); disconnect(); @@ -138,9 +140,9 @@ class Connection { _setState(SocketState.disconnected); } - void dispose() { - disconnect(); - _dataController.close(); - _stateController.close(); + Future dispose() async { + await disconnect(); + await _dataController.close(); + await _stateController.close(); } } diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index ff776c8..8562283 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -33,6 +33,12 @@ class PacketDispatcher { /// Регистрирует ожидание ответа — future завершится когда /// придёт пакет с совпадающим seq. Future registerPending(int seq) { + final existing = _pendingRequests[seq]; + if (existing != null && !existing.isCompleted) { + existing.completeError( + StateError('seq=$seq переиспользован до получения ответа'), + ); + } final completer = Completer(); _pendingRequests[seq] = completer; _requestTimestamps[seq] = DateTime.now(); @@ -55,7 +61,7 @@ class PacketDispatcher { packet.cmd == CmdType.error || packet.cmd == CmdType.notFound) { logger.i( - '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${redactForLog(packet.payload)}}', + '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${payloadForLog(packet.payload)}}', ); final completer = _pendingRequests.remove(packet.seq); @@ -84,7 +90,7 @@ class PacketDispatcher { } } else if (packet.isPush) { logger.i( - '<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${redactForLog(packet.payload)}}', + '<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${payloadForLog(packet.payload)}}', ); _pushHandlers[packet.opcode]?.call(packet); _pushController.add(packet); diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index 0c2451a..f762c9b 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -18,7 +18,7 @@ class PacketSender { final data = packPacket(opcode, payload, seq: seq); connection.write(data); logger.i( - '=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${redactForLog(payload)}}', + '=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: ${payloadForLog(payload)}}', ); return seq; } diff --git a/lib/core/transport/tls_config.dart b/lib/core/transport/tls_config.dart index 50d8639..4c118a9 100644 --- a/lib/core/transport/tls_config.dart +++ b/lib/core/transport/tls_config.dart @@ -1,9 +1,11 @@ +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; abstract class TlsConfig { static const String prefKey = 'dev_tls_insecure'; static Future isInsecureAllowed() async { + if (kReleaseMode) return false; final prefs = await SharedPreferences.getInstance(); return prefs.getBool(prefKey) ?? false; } diff --git a/lib/core/utils/log_redact.dart b/lib/core/utils/log_redact.dart index 3ad73e8..caad69f 100644 --- a/lib/core/utils/log_redact.dart +++ b/lib/core/utils/log_redact.dart @@ -1,6 +1,14 @@ +import 'package:flutter/foundation.dart'; + const _redacted = '***'; -const _sensitiveSubstrings = ['password', 'token', 'phone', 'secret']; +const _sensitiveSubstrings = [ + 'password', + 'token', + 'phone', + 'secret', + 'auth', +]; const _sensitiveExact = { 'code', @@ -12,6 +20,10 @@ const _sensitiveExact = { 'qrlink', 'text', 'msisdn', + 'deviceid', + 'mt_instanceid', + 'instanceid', + 'webappdata', }; bool _isSensitiveKey(Object? key) { @@ -37,3 +49,8 @@ dynamic redactForLog(dynamic value) { } return value; } + +Object? payloadForLog(dynamic value) { + if (kReleaseMode) return '

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%0AN+D`kEK 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