diff --git a/.gitignore b/.gitignore index f32192b..3907a8d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ PRD.md # VS Code which you may wish to be included in version control, so this line # is commented out by default. #.vscode/ +RELEASE_GUIDE.md +android/key.properties +android/app/*.jks # Flutter/Dart/Pub related **/doc/api/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d21876f --- /dev/null +++ b/LICENSE @@ -0,0 +1,662 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our general public licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, there is a terrible gap in the +overall display. The GNU General Public License permits making a +modified version and letting the public access it on a server without +ever releasing its source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the modified source code to the users of that server. +Therefore, public use of a modified version, on a publicly accessible +server, gives the public access to the source code of the modified +version. + + An older license, called the GNU Affero General Public License and +published by Affero, Inc., was designed to accomplish similar goals. +This is a different license, not a version of the Affero GPL, but +Affero, Inc. has permitted us to publish this program under the Affero +GPL. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Component, but which is not part of that Component, and +(b) serves only to enable use of the work with that Component, or to +implement a Standard Interface for which an implementation is +available to the public in source code form. A "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 the + Corresponding Source is located. 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 +to be acquired, that would be infringed by some manner, permitted by +this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not convey it at all. For example, if you agree to terms that +obligate you to collect a royalty for further conveying from those to +whom you convey the Program, the only way you could satisfy both those +terms and this License would be to refrain entirely from conveying the +Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + + You may convey a covered work under this License, and concurrently +combine it with a work licensed under version 3 of the GNU General +Public License into a single combined work, and convey the resulting +work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will +remain governed by version 3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Affero General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero +General Public License "or any later version" applies to it, you have +the option of following the terms and conditions either of that +numbered version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number +of the GNU Affero General Public License, you may choose any version +ever published by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that +proxy's public statement of acceptance of a version permanently +authorizes you to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE +OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU +ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING +ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF +THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO +LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY +OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF +THE POSSIBILITY OF SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that offers the most absolute +waiver of 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. + + FocusGram - Distraction-free Instagram client + Copyright (C) 2025 Ujwal Chapagain + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add a notice on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, +see . diff --git a/README.md b/README.md index 13cbec0..0195c5a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,83 @@ -# focusgram +# FocusGram -A new Flutter project. +[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL%203.0-blue.svg)](LICENSE) +[![Flutter](https://img.shields.io/badge/Flutter-stable-blue?logo=flutter)](https://flutter.dev) +[![GitHub Downloads](https://img.shields.io/github/downloads/Ujwal223/FocusGram/total?label=total%20installs&color=blue)](https://github.com/Ujwal223/FocusGram/releases) -## Getting Started +**Take back your time.** FocusGram is a distraction-free client for Instagram on Android that hides Reels and Explore, so you can stay connected without getting lost in the scroll. -This project is a starting point for a Flutter application. +[🌟 Star on GitHub](https://github.com/Ujwal223/FocusGram) | [πŸ“₯ Download Latest APK](https://github.com/Ujwal223/FocusGram/releases) -A few resources to get you started if this is your first Flutter project: +--- -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +## Why FocusGram? -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +Most people don't want to delete Instagram entirelyβ€”they just want to stop wasting hours on Reels. FocusGram surgically removes the parts of Instagram designed for compulsive scrolling, while keeping your feed, stories, and DMs fully functional. + +### Key Benefits +- **Mental Health**: Stop the dopamine loop of endless autoplay videos. +- **Productivity**: Open Instagram to check a message or post a story, and get out in seconds. +- **Privacy**: No tracking, no analytics, and no third-party SDKs. Your data stays on your device. + +--- + +## Master Your Usage + +FocusGram doesn't just block Reelsβ€”it gives you tools to build better habits: + +- βœ… **Controlled Reel Sessions**: Need to watch a Reel? Start a timed session (1 to 15 minutes). When the time is up, they're blocked again. +- βœ… **Daily Limits**: Set a maximum amount of Reel time per day. +- βœ… **Habit-Building Cooldowns**: Enforce a mandatory break between sessions to prevent bingeing. + +--- + +## Installation + +### 1. From GitHub (Current) +1. Go to the [Releases](https://github.com/Ujwal223/FocusGram/releases) page. +2. Download the `focusgram-release.apk`. +3. Open the file on your phone and allow "Install from unknown sources" if prompted. + +### 2. From F-Droid (Soon) +We are currently in the process of submitting FocusGram to the F-Droid store for easier updates. + +--- + +## Frequently Asked Questions + +**Is my login safe?** +Yes. FocusGram uses a standard system WebView. Your credentials go directly to Instagram/Meta's servers, just like in a mobile browser. We do not (and cannot) see your password. + +**Why is it free?** +FocusGram is Open Source software created by [Ujwal Chapagain](https://github.com/Ujwal223). It is built for everyone who wants a healthier relationship with social media. + +--- + +## Development & Technical Details + +
+View Technical Info + +### Build from Source +```bash +flutter pub get +flutter build apk --release +``` + +### Permissions +- `INTERNET`: To load Instagram. +- `RECEIVE_BOOT_COMPLETED`: To keep your session timers and notifications accurate after a restart. + +### Tech Stack +- **Framework**: Flutter (Dart) +- **Engine**: webview_flutter +- **License**: AGPL-3.0 (Affero General Public License) +
+ +--- + +## License + +Copyright (C) 2025 Ujwal Chapagain + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b1fb261..54f82fd 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -6,10 +8,26 @@ plugins { } android { - namespace = "com.focusgram.focusgram" + namespace = "com.ujwal.focusgram" compileSdk = flutter.compileSdkVersion + buildToolsVersion = "34.0.0" ndkVersion = flutter.ndkVersion + val keystorePropertiesFile = rootProject.file("key.properties") + val keystoreProperties = Properties() + if (keystorePropertiesFile.exists()) { + keystoreProperties.load(keystorePropertiesFile.inputStream()) + } + + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -17,12 +35,11 @@ android { } kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + jvmTarget = "17" } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.focusgram.focusgram" + applicationId = "com.ujwal.focusgram" // 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 @@ -33,9 +50,16 @@ android { 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") + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } else { + signingConfig = signingConfigs.getByName("debug") + } + + // Fix for release crash: Apply proguard rules + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..1b715be --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,28 @@ +# Flutter Wrapper +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } + +# flutter_local_notifications +-keep class com.dexterous.flutterlocalnotifications.** { *; } +-keep class com.google.firebase.messaging.** { *; } +-dontwarn com.google.firebase.messaging.** + +# webview_flutter +-keep class io.flutter.plugins.webviewflutter.** { *; } + +# Keystore and common +-keep class com.ujwal.focusgram.** { *; } + +# Flutter Play Store Split (ignore optional references) +-dontwarn com.google.android.play.core.** +-dontwarn com.google.android.gms.common.** + +# Avoid stripping JS bridge names +-keepattributes JavascriptInterface +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 25418d4..0a7cc0d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ android:usesCleartextTraffic="false" android:networkSecurityConfig="@xml/network_security_config"> + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt b/android/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt similarity index 74% rename from android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt rename to android/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt index b9af0c5..445ab01 100644 --- a/android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt +++ b/android/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt @@ -1,4 +1,4 @@ -package com.focusgram.focusgram +package com.ujwal.focusgram import io.flutter.embedding.android.FlutterActivity diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index efd4422..9accd00 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ 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 index c5bcdb2..625081a 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index f79d08f..9c5d635 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 0eefa50..341e142 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ 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 index ccc070e..b2f4937 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ 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 646888c..f9dcc18 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 5526198..7352438 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 1746aee..2182cb5 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 70279c5..46afce4 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 9eac20c..839c0d1 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 399f698..4d800c3 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -5,3 +5,4 @@ --> + diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..54f5680 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..b3764f0 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,2 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true diff --git a/assets/images/focusgram.ico b/assets/images/focusgram.ico index 3f6d0c1..91666f4 100644 Binary files a/assets/images/focusgram.ico and b/assets/images/focusgram.ico differ diff --git a/assets/images/focusgram.png b/assets/images/focusgram.png index 390149f..79554d0 100644 Binary files a/assets/images/focusgram.png and b/assets/images/focusgram.png differ diff --git a/fastlane/metadata/android/en-US/changelogs/1.txt b/fastlane/metadata/android/en-US/changelogs/1.txt new file mode 100644 index 0000000..45e93cb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1.txt @@ -0,0 +1,5 @@ +Initial open-source release of FocusGram. +- Complete Reels and Explore hiding. +- Timed Reel sessions and daily limits. +- Isolated DM Reel player. +- Privacy-first: No Firebase or trackers. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..02d72f4 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,9 @@ +FocusGram is a distraction-free client for Instagram that allows you to use the core featuresβ€”feed, stories, DMs, and profileβ€”without getting stuck in the endless scroll of Reels and Explore. + +Key Features: +- Adds blur in explore feeds and homepage posts. +- Blocks navigation to /reel/ URLs to prevent accidental distraction. +- Implement controlled Reel sessions with customizable time limits and daily totals. +- Enforces cooldown periods between sessions to build better habits. +- Privacy-focused: No ads, no tracking, and no proprietary SDKs. +- 100% Free and Open Source. diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..e52caa3 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Distraction-free Instagram with controlled Reel access. diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..f2affd7 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +FocusGram diff --git a/lib/main.dart b/lib/main.dart index df72110..5f4aec1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:app_links/app_links.dart'; import 'services/session_manager.dart'; import 'services/settings_service.dart'; +import 'services/focusgram_router.dart'; import 'screens/onboarding_page.dart'; import 'screens/main_webview_page.dart'; import 'screens/breath_gate_screen.dart'; import 'screens/app_session_picker.dart'; import 'screens/cooldown_gate_screen.dart'; +import 'services/notification_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -23,6 +26,7 @@ void main() async { await sessionManager.init(); await settingsService.init(); + await NotificationService().init(); runApp( MultiProvider( @@ -40,16 +44,21 @@ class FocusGramApp extends StatelessWidget { @override Widget build(BuildContext context) { + final settings = context.watch(); + final isDark = settings.isDarkMode; + return MaterialApp( title: 'FocusGram', debugShowCheckedModeBanner: false, theme: ThemeData( - brightness: Brightness.dark, - colorScheme: ColorScheme.dark( - primary: Colors.blue.shade400, - surface: Colors.black, - ), - scaffoldBackgroundColor: Colors.black, + brightness: isDark ? Brightness.dark : Brightness.light, + colorScheme: isDark + ? ColorScheme.dark( + primary: Colors.blue.shade400, + surface: Colors.black, + ) + : ColorScheme.light(primary: Colors.blue), + scaffoldBackgroundColor: isDark ? Colors.black : Colors.white, useMaterial3: true, splashColor: Colors.transparent, highlightColor: Colors.transparent, @@ -76,6 +85,29 @@ class _InitialRouteHandlerState extends State { bool _breathCompleted = false; bool _appSessionStarted = false; bool _onboardingCompleted = false; + late AppLinks _appLinks; + + @override + void initState() { + super.initState(); + _appLinks = AppLinks(); + _initDeepLinks(); + } + + Future _initDeepLinks() async { + // 1. Handle background links while app is running + _appLinks.uriLinkStream.listen((uri) { + debugPrint('Incoming Deep Link: $uri'); + FocusGramRouter.pendingUrl.value = uri.toString(); + }); + + // 2. Handle the initial link that opened the app + final initialUri = await _appLinks.getInitialLink(); + if (initialUri != null) { + debugPrint('Initial Deep Link: $initialUri'); + FocusGramRouter.pendingUrl.value = initialUri.toString(); + } + } @override Widget build(BuildContext context) { diff --git a/lib/screens/about_page.dart b/lib/screens/about_page.dart index a7e10f5..a947234 100644 --- a/lib/screens/about_page.dart +++ b/lib/screens/about_page.dart @@ -11,7 +11,7 @@ class AboutPage extends StatefulWidget { } class _AboutPageState extends State { - final String _currentVersion = '0.8.5'; + final String _currentVersion = '0.9.8'; bool _isChecking = false; Future _checkUpdate() async { @@ -110,10 +110,13 @@ class _AboutPageState extends State { color: Colors.blue.withValues(alpha: 0.1), shape: BoxShape.circle, ), - child: const Icon( - Icons.psychology, - color: Colors.blue, - size: 50, + child: ClipOval( + child: Image.asset( + 'assets/images/focusgram.png', + width: 60, + height: 60, + fit: BoxFit.cover, + ), ), ), const SizedBox(height: 24), diff --git a/lib/screens/guardrails_page.dart b/lib/screens/guardrails_page.dart index 125120d..e3c86af 100644 --- a/lib/screens/guardrails_page.dart +++ b/lib/screens/guardrails_page.dart @@ -1,19 +1,93 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/session_manager.dart'; +import '../services/settings_service.dart'; import '../utils/discipline_challenge.dart'; -class GuardrailsPage extends StatelessWidget { +class GuardrailsPage extends StatefulWidget { const GuardrailsPage({super.key}); + @override + State createState() => _GuardrailsPageState(); +} + +class _GuardrailsPageState extends State { + Future _handleScheduleAction( + BuildContext context, + SessionManager sm, + Future Function() action, + ) async { + if (sm.isScheduledBlockActive) { + final ok = await DisciplineChallenge.show(context, count: 35); + if (!context.mounted || !ok) return; + } + await action(); + } + + Future _pickNewSchedule(BuildContext context, SessionManager sm) async { + final start = await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 22, minute: 0), + helpText: 'Select Start Time', + ); + if (!context.mounted || start == null) return; + + final end = await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + helpText: 'Select End Time', + ); + if (!context.mounted || end == null) return; + + await sm.addSchedule( + FocusSchedule( + startHour: start.hour, + startMinute: start.minute, + endHour: end.hour, + endMinute: end.minute, + ), + ); + } + + Future _editExistingSchedule( + BuildContext context, + SessionManager sm, + int index, + FocusSchedule s, + ) async { + final start = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: s.startHour, minute: s.startMinute), + helpText: 'Edit Start Time', + ); + if (!context.mounted || start == null) return; + + final end = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: s.endHour, minute: s.endMinute), + helpText: 'Edit End Time', + ); + if (!context.mounted || end == null) return; + + await sm.updateScheduleAt( + index, + FocusSchedule( + startHour: start.hour, + startMinute: start.minute, + endHour: end.hour, + endMinute: end.minute, + ), + ); + } + @override Widget build(BuildContext context) { final sm = context.watch(); + final settings = context.watch(); + final isDark = settings.isDarkMode; return Scaffold( - backgroundColor: Colors.black, appBar: AppBar( - backgroundColor: Colors.black, title: const Text( 'Guardrails', style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), @@ -25,11 +99,14 @@ class GuardrailsPage extends StatelessWidget { ), body: ListView( children: [ - const Padding( - padding: EdgeInsets.all(16.0), + Padding( + padding: const EdgeInsets.all(16.0), child: Text( 'Set your limits to stay focused. Changes to these settings require a challenge.', - style: TextStyle(color: Colors.white54, fontSize: 13), + style: TextStyle( + color: isDark ? Colors.white54 : Colors.black54, + fontSize: 13, + ), ), ), _buildFrictionSliderTile( @@ -60,84 +137,83 @@ class GuardrailsPage extends StatelessWidget { 'Reducing cooldown makes it easier to start new sessions. Are you sure?', onConfirmed: (v) => sm.setCooldownMinutes(v.toInt()), ), - const Divider(color: Colors.white10, height: 32), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - 'Scheduled Blocking', + Divider(color: isDark ? Colors.white10 : Colors.black12, height: 32), + SwitchListTile( + title: const Text('Scheduled Blocking'), + subtitle: Text( + 'Block Instagram during specific hours', style: TextStyle( - color: Colors.blue, - fontWeight: FontWeight.bold, + color: isDark ? Colors.white54 : Colors.black54, fontSize: 13, ), ), - ), - SwitchListTile( - title: const Text( - 'Enable Blocking Schedule', - style: TextStyle(color: Colors.white), - ), - subtitle: const Text( - 'Block Instagram during specific hours', - style: TextStyle(color: Colors.white54, fontSize: 13), - ), value: sm.scheduleEnabled, onChanged: (v) => sm.setScheduleEnabled(v), ), if (sm.scheduleEnabled) ...[ - ListTile( - title: const Text( - 'Start Time', - style: TextStyle(color: Colors.white), - ), - trailing: Text( - '${sm.schedStartHour.toString().padLeft(2, '0')}:${sm.schedStartMin.toString().padLeft(2, '0')}', - style: const TextStyle(color: Colors.blue), - ), - onTap: () async { - final time = await showTimePicker( - context: context, - initialTime: TimeOfDay( - hour: sm.schedStartHour, - minute: sm.schedStartMin, + ...sm.schedules.asMap().entries.map((entry) { + final idx = entry.key; + final s = entry.value; + return ListTile( + title: Text( + 'Schedule ${idx + 1}', + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + '${sm.formatTime12h(s.startHour, s.startMinute)} - ${sm.formatTime12h(s.endHour, s.endMinute)}', + style: TextStyle( + color: isDark ? Colors.white54 : Colors.black54, + fontSize: 13, ), - ); - if (time != null) { - sm.setScheduleTime( - startH: time.hour, - startM: time.minute, - endH: sm.schedEndHour, - endM: sm.schedEndMin, - ); - } - }, - ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon( + Icons.edit, + color: Colors.blue, + size: 20, + ), + onPressed: () => _handleScheduleAction( + context, + sm, + () => _editExistingSchedule(context, sm, idx, s), + ), + ), + IconButton( + icon: const Icon( + Icons.delete_outline, + color: Colors.redAccent, + size: 20, + ), + onPressed: () => _handleScheduleAction( + context, + sm, + () => sm.removeScheduleAt(idx), + ), + ), + ], + ), + ); + }), ListTile( + leading: const Icon( + Icons.add_circle_outline, + color: Colors.blueAccent, + ), title: const Text( - 'End Time', - style: TextStyle(color: Colors.white), + 'Add Focus Hours', + style: TextStyle( + color: Colors.blueAccent, + fontWeight: FontWeight.w600, + ), ), - trailing: Text( - '${sm.schedEndHour.toString().padLeft(2, '0')}:${sm.schedEndMin.toString().padLeft(2, '0')}', - style: const TextStyle(color: Colors.blue), + onTap: () => _handleScheduleAction( + context, + sm, + () => _pickNewSchedule(context, sm), ), - onTap: () async { - final time = await showTimePicker( - context: context, - initialTime: TimeOfDay( - hour: sm.schedEndHour, - minute: sm.schedEndMin, - ), - ); - if (time != null) { - sm.setScheduleTime( - startH: sm.schedStartHour, - startM: sm.schedStartMin, - endH: time.hour, - endM: time.minute, - ); - } - }, ), ], ], @@ -217,18 +293,17 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> { @override Widget build(BuildContext context) { + final settings = context.watch(); + final isDark = settings.isDarkMode; final divisions = ((widget.max - widget.min) / widget.divisor).round(); return Column( children: [ ListTile( - title: Text( - widget.title, - style: const TextStyle(color: Colors.white), - ), + title: Text(widget.title), subtitle: Text( '${_draftValue.toInt()} min', - style: const TextStyle(color: Colors.white70), + style: TextStyle(color: isDark ? Colors.white70 : Colors.black54), ), trailing: _pendingConfirm ? Row( @@ -241,15 +316,22 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> { _pendingConfirm = false; }); }, - child: const Text( - 'Cancel', - style: TextStyle(color: Colors.white38), - ), + child: const Text('Cancel'), ), ElevatedButton( onPressed: () async { - final success = await DisciplineChallenge.show(context); - if (!success) return; + final sm = context.read(); + int wordCount = 15; + // If we are at 0 quota, increase difficulty to 35 words + if (widget.title.contains('Daily Reel Limit') && + sm.dailyRemainingSeconds <= 0) { + wordCount = 35; + } + final success = await DisciplineChallenge.show( + context, + count: wordCount, + ); + if (!context.mounted || !success) return; await widget.onConfirmed(_draftValue); setState(() => _pendingConfirm = false); }, diff --git a/lib/screens/main_webview_page.dart b/lib/screens/main_webview_page.dart index ced7ca1..4846edf 100644 --- a/lib/screens/main_webview_page.dart +++ b/lib/screens/main_webview_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -9,10 +10,11 @@ import '../services/session_manager.dart'; import '../services/settings_service.dart'; import '../services/injection_controller.dart'; import '../services/navigation_guard.dart'; +import '../services/focusgram_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../services/notification_service.dart'; +import '../utils/discipline_challenge.dart'; import 'settings_page.dart'; -import 'app_session_picker.dart'; class MainWebViewPage extends StatefulWidget { const MainWebViewPage({super.key}); @@ -24,15 +26,14 @@ class MainWebViewPage extends StatefulWidget { class _MainWebViewPageState extends State with WidgetsBindingObserver { late final WebViewController _controller; - int _currentIndex = 0; + final GlobalKey<_EdgePanelState> _edgePanelKey = GlobalKey<_EdgePanelState>(); bool _isLoading = true; - // Watchdog for app-session expiry Timer? _watchdog; bool _extensionDialogShown = false; bool _lastSessionActive = false; - String? _cachedUsername; String _currentUrl = 'https://www.instagram.com/'; bool _hasError = false; + bool _reelsBlockedOverlay = false; /// Helper to determine if we are on a login/onboarding page. bool get _isOnOnboardingPage { @@ -46,11 +47,6 @@ class _MainWebViewPageState extends State _currentUrl.contains('instagram.com/accounts/login'); } - /// Helper to determine if we are inside Direct Messages. - bool get _isInDirect { - return _currentUrl.contains('instagram.com/direct/'); - } - @override void initState() { super.initState(); @@ -58,12 +54,29 @@ class _MainWebViewPageState extends State _initWebView(); _startWatchdog(); - // Listen to session & settings changes WidgetsBinding.instance.addPostFrameCallback((_) { context.read().addListener(_onSessionChanged); context.read().addListener(_onSettingsChanged); _lastSessionActive = context.read().isSessionActive; }); + + FocusGramRouter.pendingUrl.addListener(_onPendingUrlChanged); + } + + void _onPendingUrlChanged() { + final url = FocusGramRouter.pendingUrl.value; + if (url != null && url.isNotEmpty) { + FocusGramRouter.pendingUrl.value = null; + _controller.loadRequest(Uri.parse(url)); + } + } + + /// Sets the isolated reel player flag in the WebView so the scroll-lock + /// knows it should block swipe-to-next-reel. + Future _setIsolatedPlayer(bool active) async { + await _controller.runJavaScript( + 'window.__focusgramIsolatedPlayer = $active;', + ); } void _onSessionChanged() { @@ -72,21 +85,26 @@ class _MainWebViewPageState extends State if (_lastSessionActive != sm.isSessionActive) { _lastSessionActive = sm.isSessionActive; _applyInjections(); + + // If session became active and we were showing overlay, hide it + if (_lastSessionActive && _reelsBlockedOverlay) { + setState(() => _reelsBlockedOverlay = false); + } } - // Force rebuild for timer updates setState(() {}); } void _onSettingsChanged() { if (!mounted) return; _applyInjections(); - _controller.reload(); + // Removed _controller.reload() to improve performance. JS injection now handles updates instantly. } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _watchdog?.cancel(); + FocusGramRouter.pendingUrl.removeListener(_onPendingUrlChanged); context.read().removeListener(_onSessionChanged); context.read().removeListener(_onSettingsChanged); super.dispose(); @@ -149,7 +167,7 @@ class _MainWebViewPageState extends State onPressed: () { Navigator.pop(context); sm.endAppSession(); - SystemNavigator.pop(); // Force close + SystemNavigator.pop(); }, child: const Text( 'Close App', @@ -161,8 +179,7 @@ class _MainWebViewPageState extends State onPressed: () { Navigator.pop(context); sm.extendAppSession(); - _extensionDialogShown = - false; // Reset so watchdog can fire again at next expiry + _extensionDialogShown = false; }, style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, @@ -178,21 +195,36 @@ class _MainWebViewPageState extends State } void _initWebView() { + final settings = context.read(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setUserAgent(InjectionController.iOSUserAgent) - ..setBackgroundColor(Colors.black); + ..setBackgroundColor(settings.isDarkMode ? Colors.black : Colors.white); - // Support file uploads on Android if (_controller.platform is AndroidWebViewController) { - AndroidWebViewController.enableDebugging(true); - (_controller.platform as AndroidWebViewController).setOnShowFileSelector(( - FileSelectorParams params, - ) async { - // Standard Android implementation triggers the file picker intent automatically - // when this callback is set, or we can use file_picker package if needed. - // For now, returning empty list if we want to custom handle, or better: - // By default, setting a non-null callback enables the system picker. + final androidController = + _controller.platform as AndroidWebViewController; + AndroidWebViewController.enableDebugging(false); + androidController.setMediaPlaybackRequiresUserGesture(false); + androidController.setOnShowFileSelector((params) async { + try { + final picker = ImagePicker(); + final acceptsVideo = params.acceptTypes.any( + (t) => t.contains('video'), + ); + final XFile? file = acceptsVideo + ? await picker.pickVideo(source: ImageSource.gallery) + : await picker.pickImage(source: ImageSource.gallery); + if (file != null) { + // WebView expects a content:// URI, not a raw filesystem path. + // XFile.path on Android is already a content:// URI string when + // picked from the gallery via image_picker >= 0.9, but if it + // starts with '/' we need to prefix it with 'file://'. + final path = file.path; + final uri = path.startsWith('/') ? 'file://$path' : path; + return [uri]; + } + } catch (_) {} return []; }); } @@ -204,7 +236,14 @@ class _MainWebViewPageState extends State if (mounted) { setState(() { _isLoading = !url.contains('#'); - _currentUrl = url; // Update immediately to hide/show UI + _currentUrl = url; + // If navigating to reels and no session, block it + if (url.contains('/reels/') && + !context.read().isSessionActive) { + _reelsBlockedOverlay = true; + } else { + _reelsBlockedOverlay = false; + } }); } }, @@ -216,75 +255,154 @@ class _MainWebViewPageState extends State }); } _applyInjections(); - _updateCurrentTab(url); - _cacheUsername(); - // Inject Notification Bridge Hook _controller.runJavaScript(InjectionController.notificationBridgeJS); - // Inject MutationObserver to lock reel scrolling resiliently - _controller.runJavaScript( - InjectionController.reelsMutationObserverJS, - ); + + // Set isolated player flag: true only when a single reel is opened + // from a DM thread (URL contains /reel/ but we're coming from /direct/). + // When the user navigates away, clear the flag. + final isIsolatedReel = + url.contains('/reel/') && !url.contains('/reels/'); + _setIsolatedPlayer(isIsolatedReel); }, onNavigationRequest: (request) { - // Handle external links (non-Instagram/Facebook) final uri = Uri.tryParse(request.url); + if (uri != null && + uri.host.contains('instagram.com') && + (request.url.contains('accounts/settings') || + request.url.contains('accounts/edit'))) { + return NavigationDecision.navigate; + } + + // Block reels feed if no session active + if (request.url.contains('/reels/') && + !context.read().isSessionActive) { + setState(() => _reelsBlockedOverlay = true); + return NavigationDecision.prevent; + } + if (uri != null && !uri.host.contains('instagram.com') && - !uri.host.contains('facebook.com')) { + !uri.host.contains('facebook.com') && + !uri.host.contains('cdninstagram.com') && + !uri.host.contains('fbcdn.net')) { launchUrl(uri, mode: LaunchMode.externalApplication); return NavigationDecision.prevent; } - // Facebook Login Warning - if (uri != null && - uri.host.contains('facebook.com') && - _isOnOnboardingPage) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Sorry, Please use Email login')), - ); - return NavigationDecision.prevent; - } - final decision = NavigationGuard.evaluate(url: request.url); - if (decision.blocked) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(decision.reason ?? 'Navigation blocked'), - backgroundColor: Colors.red.shade900, - behavior: SnackBarBehavior.floating, - margin: const EdgeInsets.fromLTRB(16, 0, 16, 80), - duration: const Duration(seconds: 2), - ), - ); + // Custom handling for reels in overlay instead of snackbar + if (request.url.contains('/reels/')) { + setState(() => _reelsBlockedOverlay = true); + return NavigationDecision.prevent; } return NavigationDecision.prevent; } return NavigationDecision.navigate; }, + onWebResourceError: (error) { + if (error.isForMainFrame == true && + (error.errorCode == -2 || error.errorCode == -6)) { + if (mounted) setState(() => _hasError = true); + } + }, ), ) ..addJavaScriptChannel( 'FocusGramNotificationChannel', onMessageReceived: (message) { + final settings = context.read(); + final msg = message.message; + + // Check if it's a bridge payload (Title: Body) or a simple flag (DM/Activity) + String title = ''; + String body = ''; + bool isDM = false; + + if (msg.contains(': ')) { + final parts = msg.split(': '); + title = parts[0]; + body = parts.sublist(1).join(': '); + isDM = + title.toLowerCase().contains('message') || + title.toLowerCase().contains('direct'); + } else { + isDM = msg == 'DM'; + title = isDM ? 'Instagram Message' : 'Instagram Notification'; + body = isDM + ? 'Someone messaged you' + : 'New activity in notifications'; + } + + if (isDM && !settings.notifyDMs) return; + if (!isDM && !settings.notifyActivity) return; + try { - // Instagram sends notification data; we bridge to native NotificationService().showNotification( id: DateTime.now().millisecond, - title: 'Instagram', - body: message.message, + title: title, + body: body, ); } catch (_) {} }, ) + ..addJavaScriptChannel( + 'FocusGramShareChannel', + onMessageReceived: (message) { + try { + final data = message.message; + String url = data; + try { + final json = RegExp(r'"url":"([^"]+)"').firstMatch(data); + if (json != null) url = json.group(1) ?? data; + } catch (_) {} + Clipboard.setData(ClipboardData(text: url)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Link copied (tracking removed)'), + backgroundColor: const Color(0xFF1A1A2E), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(16, 0, 16, 20), + ), + ); + } + } catch (_) {} + }, + ) + ..addJavaScriptChannel( + 'FocusGramThemeChannel', + onMessageReceived: (message) { + context.read().setDarkMode( + message.message == 'dark', + ); + }, + ) + ..addJavaScriptChannel( + 'FocusGramPathChannel', + onMessageReceived: (message) { + if (!mounted) return; + final path = message.message; + final sm = context.read(); + if (path.startsWith('/reels') && !sm.isSessionActive) { + // SPA navigation landed on Reels without a session β€” gate it. + setState(() => _reelsBlockedOverlay = true); + // Navigate back to home feed so the overlay has content behind it. + _controller.runJavaScript( + 'if (window.location.pathname.startsWith("/reels")) window.location.href = "/";', + ); + } else if (_reelsBlockedOverlay && !path.startsWith('/reels')) { + setState(() => _reelsBlockedOverlay = false); + } + }, + ) ..loadRequest(Uri.parse('https://www.instagram.com/accounts/login/')); } void _applyInjections() { if (!mounted) return; - if (_isOnOnboardingPage) return; // Restore native login/signup behavior + if (_isOnOnboardingPage) return; final sessionManager = context.read(); final settings = context.read(); @@ -292,7 +410,10 @@ class _MainWebViewPageState extends State sessionActive: sessionManager.isSessionActive, blurExplore: settings.blurExplore, blurReels: settings.blurReels, - ghostMode: settings.ghostMode, + ghostTyping: settings.ghostTyping, + ghostSeen: settings.ghostSeen, + ghostStories: settings.ghostStories, + ghostDmPhotos: settings.ghostDmPhotos, enableTextSelection: settings.enableTextSelection, ); _controller.runJavaScript(js); @@ -302,150 +423,141 @@ class _MainWebViewPageState extends State final manager = WebViewCookieManager(); await manager.clearCookies(); await _controller.clearCache(); - // Force immediate state update and navigation if (mounted) { setState(() { - _currentIndex = 0; - _cachedUsername = null; - _isLoading = true; // Show indicator during reload + _isLoading = true; + _reelsBlockedOverlay = false; }); await _controller.loadRequest( Uri.parse('https://www.instagram.com/accounts/login/'), ); - if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Signed out successfully'))); } } - Future _cacheUsername() async { - try { - final result = await _controller.runJavaScriptReturningResult( - "document.querySelector('header h2')?.innerText || ''", - ); - final raw = result.toString().replaceAll('"', '').replaceAll("'", ''); - if (raw.isNotEmpty && raw != 'null' && raw != 'undefined') { - _cachedUsername = raw; - } - } catch (_) {} + /// Formats [seconds] as `MM:SS` for the cooldown countdown display. + static String _fmtSeconds(int seconds) { + final m = (seconds ~/ 60).toString().padLeft(2, '0'); + final s = (seconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; } - void _updateCurrentTab(String url) { - final uri = Uri.tryParse(url); - if (uri == null) return; - final path = uri.path; - - int newIndex = _currentIndex; - if (path == '/' || path.isEmpty) { - newIndex = 0; - } else if (path.startsWith('/explore') || path.startsWith('/search')) { - newIndex = 1; - } else if (path.startsWith('/direct')) { - newIndex = 3; - } else if (_cachedUsername != null && - path.startsWith('/$_cachedUsername')) { - newIndex = 4; - } - - if (newIndex != _currentIndex) { - setState(() => _currentIndex = newIndex); + Future _showReelSessionPicker() async { + final settings = context.read(); + if (settings.requireWordChallenge) { + final passed = await DisciplineChallenge.show(context); + if (!passed || !mounted) return; } + _showReelSessionPickerBottomSheet(); } - /// Navigate using JS when already on Instagram (avoids full page reload). - /// Falls back to loadRequest if not on instagram.com. - Future _navigateTo(String path) async { - try { - final currentUrl = await _controller.currentUrl(); - if (currentUrl != null && currentUrl.contains('instagram.com')) { - // SPA soft nav β€” instant, no full reload - await _controller.runJavaScript( - InjectionController.softNavigateJS(path), - ); - return; - } - } catch (_) {} - // Fallback: full load - await _controller.loadRequest(Uri.parse('https://www.instagram.com$path')); - } - - Future _onTabTapped(String label) async { - final sm = context.read(); - - switch (label) { - case 'Home': - await _navigateTo('/'); - break; - case 'Search': - await _navigateTo('/explore/'); - break; - case 'Create': - await _navigateTo('/reels/create/'); // Default create path - break; - case 'Notifications': - await _navigateTo('/notifications/'); - break; - case 'Reels': - if (sm.isSessionActive) { - await _navigateTo('/reels/'); - } else { - // Show session picker if no session active - _showSessionPicker(); - } - break; - case 'Profile': - if (_cachedUsername != null) { - await _navigateTo('/$_cachedUsername/'); - } else { - await _cacheUsername(); - if (_cachedUsername != null) { - await _navigateTo('/$_cachedUsername/'); - } else { - await _navigateTo('/accounts/edit/'); - } - } - break; - } - } - - void _showSessionPicker() { + void _showReelSessionPickerBottomSheet() { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => Container( height: MediaQuery.of(context).size.height * 0.7, - decoration: const BoxDecoration( - color: Color(0xFF121212), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(25), - topRight: Radius.circular(25), - ), + decoration: BoxDecoration( + color: const Color(0xFF121212), + borderRadius: BorderRadius.circular(25), ), - child: ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(25), - topRight: Radius.circular(25), - ), - child: Navigator( - onGenerateRoute: (_) => MaterialPageRoute( - builder: (ctx) => AppSessionPickerScreen( - onSessionStarted: () => Navigator.pop(context), + child: Column( + children: [ + const SizedBox(height: 16), + const Text( + 'Start Reel Session', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, ), ), - ), + const Padding( + padding: EdgeInsets.all(12), + child: Text( + 'Reels will be unblocked for the duration you choose.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white54, fontSize: 13), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20), + children: [ + _buildReelSessionTile(5), + _buildReelSessionTile(10), + _buildReelSessionTile(15), + const SizedBox(height: 40), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.white38), + ), + ), + ], + ), + ), + ], ), ), ); } + Widget _buildReelSessionTile(int mins) { + final sm = context.read(); + return ListTile( + title: Text('$mins Minutes', style: const TextStyle(color: Colors.white)), + trailing: const Icon( + Icons.arrow_forward_ios, + color: Colors.white24, + size: 14, + ), + onTap: () { + Navigator.pop(context); + if (sm.startSession(mins)) { + setState(() => _reelsBlockedOverlay = false); + _controller.loadRequest( + Uri.parse('https://www.instagram.com/reels/'), + ); + } + }, + ); + } + @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; + if (_reelsBlockedOverlay) { + setState(() => _reelsBlockedOverlay = false); + _controller.goBack(); + return; + } + // Run history.back() in the WebView JS context first. + // This properly closes Instagram's comment sheet / modal overlay + // (which uses the History API pushState). If the webview itself + // can go back in its own page-level history, canGoBack() handles it. + // We use a JS promise to detect whether we actually navigated: + final didNavigate = await _controller + .runJavaScriptReturningResult( + '(function(){' + ' var before = window.location.href;' + ' history.back();' + ' return before;' + '})()', + ) + .then((_) => true) + .catchError((_) => false); + if (didNavigate) { + // history.back() was called β€” wait a frame to let the SPA handle it + // If the URL didn't change (e.g., no more history states), fall + // through to webview-level back or app exit. + await Future.delayed(const Duration(milliseconds: 120)); + return; + } if (await _controller.canGoBack()) { await _controller.goBack(); } else { @@ -453,19 +565,73 @@ class _MainWebViewPageState extends State } }, child: Scaffold( - backgroundColor: Colors.black, + backgroundColor: context.watch().isDarkMode + ? Colors.black + : Colors.white, body: Stack( children: [ - // ── Main Content Layout ──────────────────────────────────── SafeArea( child: Column( children: [ - if (!_isOnOnboardingPage) _BrandedTopBar(), - Expanded(child: WebViewWidget(controller: _controller)), + if (!_isOnOnboardingPage) + _BrandedTopBar( + onFocusControlTap: () => + _edgePanelKey.currentState?._toggleExpansion(), + ), + Expanded( + child: Consumer( + builder: (ctx, sm, _) { + if (sm.isScheduledBlockActive) { + return Container( + color: Colors.black, + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.bedtime_rounded, + color: Colors.blueAccent, + size: 80, + ), + const SizedBox(height: 24), + Text( + 'Focus Hours Active', + style: GoogleFonts.grandHotel( + color: Colors.white, + fontSize: 42, + ), + ), + const SizedBox(height: 12), + Text( + 'Instagram is blocked according to your schedule (${sm.activeScheduleText ?? 'Focus Hours'}).', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontSize: 15, + height: 1.5, + ), + ), + const SizedBox(height: 48), + const Text( + 'Your future self will thank you for the extra sleep and focus.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white38, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ); + } + return WebViewWidget(controller: _controller); + }, + ), + ), ], ), ), - if (_hasError) _NoInternetScreen( onRetry: () { @@ -473,8 +639,6 @@ class _MainWebViewPageState extends State _controller.reload(); }, ), - - // ── Thin loading indicator (Placed below Top Bar) ────────── if (_isLoading) Positioned( top: 60 + MediaQuery.of(context).padding.top, @@ -482,17 +646,166 @@ class _MainWebViewPageState extends State right: 0, child: const _InstagramGradientProgressBar(), ), + _EdgePanel(key: _edgePanelKey, controller: _controller), - // ── The Edge Panel ────────────────────────────────────────── - _EdgePanel(controller: _controller), + if (_reelsBlockedOverlay) + Positioned.fill( + child: Consumer( + builder: (ctx, settings, _) { + final isDark = settings.isDarkMode; + final bg = isDark ? Colors.black : Colors.white; + final textMain = isDark ? Colors.white : Colors.black; + final textDim = isDark ? Colors.white70 : Colors.black87; + final textSub = isDark ? Colors.white38 : Colors.black45; - // ── Our bottom bar ────────────────────────────────────────── - if (!_isOnOnboardingPage && !_isInDirect) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: const _FocusGramNavBar(), + return Container( + color: bg.withValues(alpha: 0.95), + padding: const EdgeInsets.all(32), + child: Consumer( + builder: (ctx, sm, _) { + final onCooldown = sm.isCooldownActive; + final quotaFinished = sm.dailyRemainingSeconds <= 0; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + quotaFinished + ? Icons.timer_off_rounded + : Icons.lock_clock_rounded, + color: quotaFinished + ? Colors.redAccent + : Colors.blueAccent, + size: 80, + ), + const SizedBox(height: 24), + Text( + quotaFinished + ? 'Daily Quota Finished' + : 'Reels are Blocked', + style: GoogleFonts.grandHotel( + color: textMain, + fontSize: 42, + ), + ), + const SizedBox(height: 12), + Text( + quotaFinished + ? 'You have reached your planned limit for today. Step away and focus on what matters most.' + : 'Start a planned reel session to access the feed. Use Instagram for connection, not distraction.', + textAlign: TextAlign.center, + style: TextStyle( + color: textDim, + fontSize: 15, + height: 1.5, + ), + ), + const SizedBox(height: 48), + if (quotaFinished) ...[ + Text( + 'Your discipline is your strength.', + style: TextStyle( + color: Colors.greenAccent.withValues( + alpha: 0.8, + ), + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 24), + Text( + 'To adjust your daily limit, go to Settings > Guardrails.', + textAlign: TextAlign.center, + style: TextStyle( + color: textSub, + fontSize: 12, + ), + ), + ] else if (onCooldown) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 14, + ), + decoration: BoxDecoration( + color: Colors.orange.withValues( + alpha: 0.12, + ), + borderRadius: BorderRadius.circular(30), + border: Border.all( + color: Colors.orange.withValues( + alpha: 0.4, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.hourglass_bottom_rounded, + color: Colors.orangeAccent, + size: 18, + ), + const SizedBox(width: 8), + Text( + 'Cooldown: ${_fmtSeconds(sm.cooldownRemainingSeconds)}', + style: const TextStyle( + color: Colors.orangeAccent, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + 'Wait for the cooldown to expire before starting a new session.', + textAlign: TextAlign.center, + style: TextStyle( + color: textSub, + fontSize: 12, + ), + ), + ] else + ElevatedButton( + onPressed: _showReelSessionPicker, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blueAccent, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 40, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + child: const Text( + 'Start Session', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 20), + TextButton( + onPressed: () { + setState(() => _reelsBlockedOverlay = false); + _controller.goBack(); + }, + child: Text( + 'Go Back', + style: TextStyle(color: textSub), + ), + ), + ], + ); + }, + ), + ); + }, + ), ), ], ), @@ -501,36 +814,16 @@ class _MainWebViewPageState extends State } } -// ────────────────────────────────────────────────────────────────────────────── -// Edge Panel Widget β€” Samsung-style swipe-to-reveal side panel -// ────────────────────────────────────────────────────────────────────────────── - class _EdgePanel extends StatefulWidget { final WebViewController controller; - const _EdgePanel({required this.controller}); - + const _EdgePanel({super.key, required this.controller}); @override State<_EdgePanel> createState() => _EdgePanelState(); } class _EdgePanelState extends State<_EdgePanel> { bool _isExpanded = false; - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - void _toggleExpansion() { - setState(() { - _isExpanded = !_isExpanded; - }); - } + void _toggleExpansion() => setState(() => _isExpanded = !_isExpanded); @override Widget build(BuildContext context) { @@ -539,16 +832,17 @@ class _EdgePanelState extends State<_EdgePanel> { final double progress = sm.perSessionSeconds > 0 ? (remaining / sm.perSessionSeconds).clamp(0.0, 1.0) : 0; + Color barColor = progress < 0.2 + ? Colors.redAccent + : (progress < 0.5 ? Colors.yellowAccent : Colors.blueAccent); - Color barColor = Colors.grey.withValues(alpha: 0.6); - if (progress < 0.2) { - barColor = Colors.redAccent; - } else if (progress < 0.5) { - barColor = Colors.yellowAccent.withValues(alpha: 0.8); - } + final settings = context.watch(); + final isDark = settings.isDarkMode; + final panelBg = isDark ? const Color(0xFF121212) : Colors.white; + final textDim = isDark ? Colors.white70 : Colors.black87; + final textSub = isDark ? Colors.white30 : Colors.black38; + final border = isDark ? Colors.white12 : Colors.black12; - // We use a transparent Stack filling the screen to position elements anywhere. - // Hits will pass through the Stack to the WebView except on our children. return Stack( children: [ if (_isExpanded) @@ -556,228 +850,162 @@ class _EdgePanelState extends State<_EdgePanel> { child: GestureDetector( onTap: _toggleExpansion, behavior: HitTestBehavior.opaque, - child: Container(color: Colors.black.withValues(alpha: 0.15)), - ), - ), - - // ── The Handle (Minimized State) ── - if (!_isExpanded) - Positioned( - left: 0, - top: MediaQuery.of(context).size.height * 0.35 + 30, // Added margin - child: Material( - color: Colors.transparent, - child: Column( - children: [ - GestureDetector( - onHorizontalDragUpdate: (details) { - if (details.delta.dx > 10) _toggleExpansion(); - }, - onTap: _toggleExpansion, - child: Container( - width: 10, - height: 100, - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(10), - bottomRight: Radius.circular(10), - ), - border: Border.all(color: Colors.white24, width: 0.5), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 10, - ), - ], - ), - padding: const EdgeInsets.symmetric( - vertical: 6, - horizontal: 2, - ), - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - width: 4, - decoration: BoxDecoration( - color: barColor, - borderRadius: BorderRadius.circular(4), - ), - // Height determined by progress - height: (progress * 88).clamp(4.0, 88.0), - ), - ), - ), - ), - const SizedBox(height: 12), - // Gear icon below handle - GestureDetector( - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const SettingsPage()), - ), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - shape: BoxShape.circle, - border: Border.all(color: Colors.white24, width: 0.5), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 8, - ), - ], - ), - child: const Icon( - Icons.settings_rounded, - color: Colors.white70, - size: 18, - ), - ), - ), - ], + child: Container( + color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.05), ), ), ), - - // ── The Panel (Expanded State) ── AnimatedPositioned( duration: const Duration(milliseconds: 350), curve: Curves.easeOutQuart, left: _isExpanded ? 0 : -220, - top: MediaQuery.of(context).size.height * 0.25 + 30, // Added margin - child: GestureDetector( - onHorizontalDragUpdate: (details) { - if (details.delta.dx < -10) _toggleExpansion(); - }, - child: Container( - width: 210, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: const Color(0xFF121212).withValues(alpha: 0.98), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(28), - bottomRight: Radius.circular(28), - ), - border: Border.all(color: Colors.white12, width: 0.5), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.6), - blurRadius: 30, - spreadRadius: 5, - ), - ], + top: MediaQuery.of(context).size.height * 0.25 + 30, + child: Container( + width: 210, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: panelBg.withValues(alpha: 0.98), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(28), + bottomRight: Radius.circular(28), ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'FOCUS CONTROL', - style: TextStyle( - color: Colors.blueAccent, - fontSize: 10, - fontWeight: FontWeight.bold, - letterSpacing: 1.5, - ), + border: Border.all(color: border, width: 0.5), + boxShadow: [ + BoxShadow( + color: (isDark ? Colors.black : Colors.black12).withValues( + alpha: 0.3, + ), + blurRadius: 30, + spreadRadius: 5, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'FOCUS CONTROL', + style: TextStyle( + color: Colors.blueAccent, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, ), - IconButton( - icon: const Icon( - Icons.chevron_left_rounded, - color: Colors.white70, - size: 28, - ), - onPressed: _toggleExpansion, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), + ), + IconButton( + icon: Icon( + Icons.chevron_left_rounded, + color: textDim, + size: 28, ), - ], - ), - const SizedBox(height: 32), - // Reel Session Timer - const Text( - 'REEL SESSION', - style: TextStyle( - color: Colors.white30, - fontSize: 11, - letterSpacing: 1, + onPressed: _toggleExpansion, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), + ], + ), + const SizedBox(height: 32), + Text( + 'REEL SESSION', + style: TextStyle( + color: textSub, + fontSize: 11, + letterSpacing: 1, ), - const SizedBox(height: 8), - Text( - context.read().isSessionActive - ? _formatTime( - context - .read() - .remainingSessionSeconds, - ) - : 'Off', - style: TextStyle( - color: barColor, - fontSize: 40, - fontWeight: FontWeight.w200, - letterSpacing: 2, - ), + ), + const SizedBox(height: 8), + Text( + sm.isSessionActive + ? _formatTime(sm.remainingSessionSeconds) + : 'Off', + style: TextStyle( + color: barColor, + fontSize: 40, + fontWeight: FontWeight.w200, + letterSpacing: 2, ), - const SizedBox(height: 20), - _buildStatRow( - 'REEL QUOTA', - '${sm.dailyRemainingSeconds ~/ 60}m Left', - Icons.timer_outlined, - ), - _buildStatRow( - 'AUTO-CLOSE', - _formatTime(sm.appSessionRemainingSeconds), - Icons.hourglass_empty_rounded, - ), - _buildStatRow( - 'COOLDOWN', - sm.isCooldownActive - ? _formatTime(sm.cooldownRemainingSeconds) - : 'Off', - Icons.coffee_rounded, - isWarning: sm.isCooldownActive, - ), - const SizedBox(height: 32), - if (!context - .findAncestorStateOfType<_MainWebViewPageState>()! - ._isOnOnboardingPage) ...[ - const Divider(color: Colors.white10), - const SizedBox(height: 8), - ListTile( - onTap: () async { - _toggleExpansion(); - final state = context - .findAncestorStateOfType<_MainWebViewPageState>(); - if (state != null) { - await state._signOut(); - } + ), + const SizedBox(height: 20), + _buildStatRow( + 'REEL QUOTA', + '${sm.dailyRemainingSeconds ~/ 60}m Left', + Icons.timer_outlined, + isDark: isDark, + ), + _buildStatRow( + 'AUTO-CLOSE', + _formatTime(sm.appSessionRemainingSeconds), + Icons.hourglass_empty_rounded, + isDark: isDark, + ), + _buildStatRow( + 'COOLDOWN', + sm.isCooldownActive + ? _formatTime(sm.cooldownRemainingSeconds) + : 'Off', + Icons.coffee_rounded, + isWarning: sm.isCooldownActive, + isDark: isDark, + ), + if (!sm.isSessionActive && sm.dailyRemainingSeconds > 0) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + context + .findAncestorStateOfType<_MainWebViewPageState>() + ?._showReelSessionPicker(); }, - leading: const Icon( - Icons.logout_rounded, - color: Colors.redAccent, - size: 20, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blueAccent, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), - title: const Text( - 'Switch Account', + child: const Text( + 'Start Session', style: TextStyle( - color: Colors.redAccent, fontSize: 13, fontWeight: FontWeight.bold, ), ), - dense: true, - contentPadding: EdgeInsets.zero, ), - const SizedBox(height: 8), - ], + ), ], - ), + const SizedBox(height: 32), + Divider(color: isDark ? Colors.white10 : Colors.black12), + const SizedBox(height: 8), + ListTile( + onTap: () { + _toggleExpansion(); + context + .findAncestorStateOfType<_MainWebViewPageState>() + ?._signOut(); + }, + leading: const Icon( + Icons.logout_rounded, + color: Colors.redAccent, + size: 20, + ), + title: const Text( + 'Switch Account', + style: TextStyle( + color: Colors.redAccent, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + dense: true, + contentPadding: EdgeInsets.zero, + ), + ], ), ), ), @@ -790,7 +1018,10 @@ class _EdgePanelState extends State<_EdgePanel> { String value, IconData icon, { bool isWarning = false, + bool isDark = true, }) { + final textMain = isDark ? Colors.white : Colors.black; + final textSub = isDark ? Colors.white38 : Colors.black38; return Padding( padding: const EdgeInsets.only(bottom: 20), child: Row( @@ -800,12 +1031,16 @@ class _EdgePanelState extends State<_EdgePanel> { decoration: BoxDecoration( color: isWarning ? Colors.redAccent.withValues(alpha: 0.1) - : Colors.white.withValues(alpha: 0.05), + : (isDark ? Colors.white : Colors.black).withValues( + alpha: 0.05, + ), borderRadius: BorderRadius.circular(10), ), child: Icon( icon, - color: isWarning ? Colors.redAccent : Colors.white70, + color: isWarning + ? Colors.redAccent + : (isDark ? Colors.white70 : Colors.black54), size: 16, ), ), @@ -815,8 +1050,8 @@ class _EdgePanelState extends State<_EdgePanel> { children: [ Text( label, - style: const TextStyle( - color: Colors.white38, + style: TextStyle( + color: textSub, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 1, @@ -826,7 +1061,7 @@ class _EdgePanelState extends State<_EdgePanel> { Text( value, style: TextStyle( - color: isWarning ? Colors.redAccent : Colors.white, + color: isWarning ? Colors.redAccent : textMain, fontSize: 18, fontWeight: FontWeight.w600, fontFeatures: const [FontFeature.tabularFigures()], @@ -846,31 +1081,53 @@ class _EdgePanelState extends State<_EdgePanel> { } } -// ────────────────────────────────────────────────────────────────────────────── -// Branded Top Bar β€” minimal, Instagram-like font -// ────────────────────────────────────────────────────────────────────────────── - class _BrandedTopBar extends StatelessWidget { + final VoidCallback? onFocusControlTap; + const _BrandedTopBar({this.onFocusControlTap}); @override Widget build(BuildContext context) { + final isDark = context.watch().isDarkMode; + final barBg = isDark ? Colors.black : Colors.white; + final textMain = isDark ? Colors.white : Colors.black; + final iconColor = isDark ? Colors.white70 : Colors.black54; + final border = isDark ? Colors.white12 : Colors.black12; + return Container( height: 60, - decoration: const BoxDecoration( - color: Colors.black, - border: Border(bottom: BorderSide(color: Colors.white12, width: 0.5)), + decoration: BoxDecoration( + color: barBg, + border: Border(bottom: BorderSide(color: border, width: 0.5)), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'FocusGram', - style: GoogleFonts.grandHotel( - color: Colors.white, - fontSize: 32, - letterSpacing: 0.5, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon(Icons.settings_outlined, color: iconColor, size: 22), + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsPage()), + ), ), - ), - ], + Text( + 'FocusGram', + style: GoogleFonts.grandHotel( + color: textMain, + fontSize: 32, + letterSpacing: 0.5, + ), + ), + IconButton( + icon: const Icon( + Icons.timer_outlined, + color: Colors.blueAccent, + size: 22, + ), + onPressed: onFocusControlTap, + ), + ], + ), ), ); } @@ -878,161 +1135,70 @@ class _BrandedTopBar extends StatelessWidget { class _InstagramGradientProgressBar extends StatelessWidget { const _InstagramGradientProgressBar(); - @override Widget build(BuildContext context) { return SizedBox( height: 2.5, - child: Stack( - children: [ - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Color(0xFFFEDA75), // Yellow - Color(0xFFFA7E1E), // Orange - Color(0xFFD62976), // Pink - Color(0xFF962FBF), // Purple - Color(0xFF4F5BD5), // Blue - ], - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ), - ), - ), - ], - ), - ); - } -} - -class _FocusGramNavBar extends StatelessWidget { - const _FocusGramNavBar(); - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - final allItems = { - 'Home': (Icons.home_outlined, Icons.home_rounded), - 'Search': (Icons.search, Icons.search), - 'Create': (Icons.add_box_outlined, Icons.add_box), - 'Notifications': (Icons.favorite_border, Icons.favorite), - 'Reels': (Icons.play_circle_outline, Icons.play_circle_filled), - 'Profile': (Icons.person_outline, Icons.person), - }; - - final activeTabs = settings.enabledTabs; - final state = context.findAncestorStateOfType<_MainWebViewPageState>()!; - final sm = context.watch(); - - return Container( - color: Colors.black, - child: SafeArea( - top: false, - child: Container( - height: 56, - decoration: const BoxDecoration( - border: Border(top: BorderSide(color: Colors.white12, width: 0.5)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: activeTabs.map((tab) { - final icons = allItems[tab]; - if (icons == null) return const SizedBox(); - - final isSelected = _isTabSelected( - tab, - state._currentUrl, - state._cachedUsername, - ); - - return GestureDetector( - onTap: () => state._onTabTapped(tab), - behavior: HitTestBehavior.opaque, - child: SizedBox( - width: - MediaQuery.of(context).size.width / - activeTabs.length.clamp(1, 6), - height: double.infinity, - child: Center( - child: Icon( - isSelected ? icons.$2 : icons.$1, - color: isSelected - ? Colors.white - : (tab == 'Reels' && !sm.isSessionActive) - ? Colors.white24 - : Colors.white54, - size: 26, - ), - ), - ), - ); - }).toList(), + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFFEDA75), + Color(0xFFFA7E1E), + Color(0xFFD62976), + Color(0xFF962FBF), + Color(0xFF4F5BD5), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, ), ), ), ); } - - bool _isTabSelected(String tab, String url, String? username) { - final path = Uri.tryParse(url)?.path ?? ''; - switch (tab) { - case 'Home': - return path == '/' || path.isEmpty; - case 'Search': - return path.startsWith('/explore'); - case 'Create': - return path.startsWith('/create'); - case 'Notifications': - return path.startsWith('/notifications'); - case 'Reels': - return path.startsWith('/reels'); - case 'Profile': - return username != null && path.startsWith('/$username'); - default: - return false; - } - } } -// ────────────────────────────────────────────────────────────────────────────── -// No Internet Screen β€” minimal, branded -// ────────────────────────────────────────────────────────────────────────────── - class _NoInternetScreen extends StatelessWidget { final VoidCallback onRetry; const _NoInternetScreen({required this.onRetry}); - @override Widget build(BuildContext context) { + final isDark = context.watch().isDarkMode; return Container( - color: Colors.black, + color: isDark ? Colors.black : Colors.white, width: double.infinity, height: double.infinity, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.wifi_off_rounded, color: Colors.white24, size: 80), + Icon( + Icons.wifi_off_rounded, + color: isDark ? Colors.white24 : Colors.black12, + size: 80, + ), const SizedBox(height: 24), - const Text( + Text( 'No Connection', style: TextStyle( - color: Colors.white, + color: isDark ? Colors.white : Colors.black, fontSize: 20, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), - const Text( + Text( 'Please check your internet settings.', - style: TextStyle(color: Colors.white38, fontSize: 14), + style: TextStyle( + color: isDark ? Colors.white38 : Colors.black38, + fontSize: 14, + ), ), const SizedBox(height: 40), ElevatedButton( onPressed: onRetry, style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, + backgroundColor: isDark ? Colors.white : Colors.black, + foregroundColor: isDark ? Colors.black : Colors.white, padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), diff --git a/lib/screens/onboarding_page.dart b/lib/screens/onboarding_page.dart index 22dfeeb..e3d21b4 100644 --- a/lib/screens/onboarding_page.dart +++ b/lib/screens/onboarding_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:app_settings/app_settings.dart'; import '../services/settings_service.dart'; import '../services/notification_service.dart'; @@ -39,6 +40,14 @@ class _OnboardingPageState extends State { icon: Icons.timer, color: Colors.orange, ), + OnboardingData( + title: 'Open Links in FocusGram', + description: + 'To open Instagram links directly here: Tap "Configure", then "Open by default" -> "Add link" and select all.', + icon: Icons.link, + color: Colors.cyan, + isAppSettingsPage: true, + ), OnboardingData( title: 'Upload Content', description: @@ -101,48 +110,53 @@ class _OnboardingPageState extends State { child: SizedBox( width: double.infinity, height: 56, - child: ElevatedButton( - onPressed: () async { - if (_pages[_currentPage].isPermissionPage) { - if (_pages[_currentPage].permission != null) { - await _pages[_currentPage].permission!.request(); - } - if (_pages[_currentPage].title == 'Stay Notified') { - await NotificationService().init(); - } - if (_currentPage == _pages.length - 1) { - _finish(); - } else { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - } else if (_currentPage < _pages.length - 1) { - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } else { - _finish(); - } + child: Builder( + builder: (context) { + final data = _pages[_currentPage]; + return ElevatedButton( + onPressed: () async { + if (data.isAppSettingsPage) { + await AppSettings.openAppSettings( + type: AppSettingsType.settings, + ); + } else if (data.isPermissionPage) { + if (data.permission != null) { + await data.permission!.request(); + } + if (data.title == 'Stay Notified') { + await NotificationService().init(); + } + } + + if (_currentPage == _pages.length - 1) { + _finish(); + } else { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + _currentPage == _pages.length - 1 + ? 'Get Started' + : (data.isAppSettingsPage + ? 'Configure' + : 'Next'), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ); }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: Text( - _currentPage == _pages.length - 1 - ? 'Get Started' - : 'Next', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), ), ), ), @@ -166,6 +180,7 @@ class OnboardingData { final IconData icon; final Color color; final bool isPermissionPage; + final bool isAppSettingsPage; final Permission? permission; OnboardingData({ @@ -174,6 +189,7 @@ class OnboardingData { required this.icon, required this.color, this.isPermissionPage = false, + this.isAppSettingsPage = false, this.permission, }); } diff --git a/lib/screens/reel_player_overlay.dart b/lib/screens/reel_player_overlay.dart index 87e9049..cadba89 100644 --- a/lib/screens/reel_player_overlay.dart +++ b/lib/screens/reel_player_overlay.dart @@ -32,6 +32,10 @@ class _ReelPlayerOverlayState extends State { ..setNavigationDelegate( NavigationDelegate( onPageFinished: (url) { + // Set isolated player flag to ensure scroll-lock applies even if a session is active globally + _controller.runJavaScript( + 'window.__focusgramIsolatedPlayer = true;', + ); // Apply scroll-lock via MutationObserver: prevents swiping to next reel _controller.runJavaScript( InjectionController.reelsMutationObserverJS, @@ -42,7 +46,10 @@ class _ReelPlayerOverlayState extends State { sessionActive: true, blurExplore: false, blurReels: false, - ghostMode: false, + ghostTyping: false, + ghostSeen: false, + ghostStories: false, + ghostDmPhotos: false, enableTextSelection: true, ), ); diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index cfd2349..e92971c 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/session_manager.dart'; import '../services/settings_service.dart'; -import 'package:url_launcher/url_launcher.dart'; +import '../services/focusgram_router.dart'; import 'guardrails_page.dart'; import 'about_page.dart'; @@ -13,11 +13,11 @@ class SettingsPage extends StatelessWidget { Widget build(BuildContext context) { // Watching services ensures the UI rebuilds when settings or session state change. final sm = context.watch(); + final settings = context.watch(); + final isDark = settings.isDarkMode; return Scaffold( - backgroundColor: Colors.black, appBar: AppBar( - backgroundColor: Colors.black, title: const Text( 'FocusGram', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), @@ -55,6 +55,13 @@ class SettingsPage extends StatelessWidget { icon: Icons.extension_outlined, destination: const _ExtrasSettingsPage(), ), + _buildSettingsTile( + context: context, + title: 'Notifications', + subtitle: 'Manage message and activity alerts', + icon: Icons.notifications_active_outlined, + destination: const _NotificationSettingsPage(), + ), _buildSettingsTile( context: context, title: 'About', @@ -63,44 +70,39 @@ class SettingsPage extends StatelessWidget { destination: const AboutPage(), ), - const Divider( - color: Colors.white10, - height: 40, - indent: 16, - endIndent: 16, - ), + const Divider(height: 40, indent: 16, endIndent: 16), ListTile( leading: const Icon( Icons.settings_outlined, color: Colors.purpleAccent, ), - title: const Text( - 'Instagram Settings', - style: TextStyle(color: Colors.white), - ), + title: const Text('Instagram Settings'), subtitle: const Text( 'Open native Instagram account settings', - style: TextStyle(color: Colors.white54, fontSize: 12), + style: TextStyle(fontSize: 12), ), trailing: const Icon( Icons.open_in_new, color: Colors.white24, size: 14, ), - onTap: () async { - final uri = Uri.parse( - 'https://www.instagram.com/accounts/settings/?entrypoint=profile', - ); - await launchUrl(uri, mode: LaunchMode.externalApplication); + onTap: () { + // Bug 6 fix: navigate inside the WebView instead of external browser + Navigator.pop(context); + FocusGramRouter.pendingUrl.value = + 'https://www.instagram.com/accounts/settings/?entrypoint=profile'; }, ), const SizedBox(height: 40), - const Center( + Center( child: Text( 'FocusGram Β· Built for discipline', - style: TextStyle(color: Colors.white12, fontSize: 12), + style: TextStyle( + color: isDark ? Colors.white12 : Colors.black12, + fontSize: 12, + ), ), ), const SizedBox(height: 24), @@ -118,16 +120,9 @@ class SettingsPage extends StatelessWidget { }) { return ListTile( leading: Icon(icon, color: Colors.blue), - title: Text(title, style: const TextStyle(color: Colors.white)), - subtitle: Text( - subtitle, - style: const TextStyle(color: Colors.white54, fontSize: 13), - ), - trailing: const Icon( - Icons.arrow_forward_ios, - color: Colors.white24, - size: 14, - ), + title: Text(title), + subtitle: Text(subtitle, style: const TextStyle(fontSize: 13)), + trailing: const Icon(Icons.arrow_forward_ios, size: 14), onTap: () => Navigator.push( context, MaterialPageRoute(builder: (_) => destination), @@ -140,9 +135,9 @@ class SettingsPage extends StatelessWidget { margin: const EdgeInsets.fromLTRB(16, 20, 16, 4), padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF111111), + color: Colors.blue.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white10), + border: Border.all(color: Colors.blue.withValues(alpha: 0.1)), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, @@ -177,16 +172,16 @@ class SettingsPage extends StatelessWidget { ), ), const SizedBox(height: 4), - Text( - label, - style: const TextStyle(color: Colors.white38, fontSize: 11), - ), + Text(label, style: const TextStyle(color: Colors.grey, fontSize: 11)), ], ); } - Widget _dividerCell() => - Container(width: 1, height: 36, color: Colors.white10); + Widget _dividerCell() => Container( + width: 1, + height: 36, + color: Colors.blue.withValues(alpha: 0.1), + ); } class _DistractionSettingsPage extends StatelessWidget { @@ -196,9 +191,7 @@ class _DistractionSettingsPage extends StatelessWidget { Widget build(BuildContext context) { final settings = context.watch(); return Scaffold( - backgroundColor: Colors.black, appBar: AppBar( - backgroundColor: Colors.black, title: const Text( 'Distraction Management', style: TextStyle(fontSize: 17), @@ -211,52 +204,30 @@ class _DistractionSettingsPage extends StatelessWidget { body: ListView( children: [ SwitchListTile( - title: const Text( - 'Blur Explore feed', - style: TextStyle(color: Colors.white), - ), + title: const Text('Blur Posts and Explore'), subtitle: const Text( - 'Blurs posts and reels in Explore by default', - style: TextStyle(color: Colors.white54, fontSize: 13), + 'Blurs images and videos on the home feed and Explore page', + style: TextStyle(fontSize: 13), ), value: settings.blurExplore, onChanged: (v) => settings.setBlurExplore(v), activeThumbColor: Colors.blue, ), SwitchListTile( - title: const Text( - 'Mindfulness Gate', - style: TextStyle(color: Colors.white), - ), + title: const Text('Mindfulness Gate'), subtitle: const Text( 'Show breathing exercise before opening', - style: TextStyle(color: Colors.white54, fontSize: 13), + style: TextStyle(fontSize: 13), ), value: settings.showBreathGate, onChanged: (v) => settings.setShowBreathGate(v), activeThumbColor: Colors.blue, ), SwitchListTile( - title: const Text( - 'Long-press for Session', - style: TextStyle(color: Colors.white), - ), - subtitle: const Text( - 'Requires 2s hold to start a Reel session', - style: TextStyle(color: Colors.white54, fontSize: 13), - ), - value: settings.requireLongPress, - onChanged: (v) => settings.setRequireLongPress(v), - activeThumbColor: Colors.blue, - ), - SwitchListTile( - title: const Text( - 'Strict Changes (Word Challenge)', - style: TextStyle(color: Colors.white), - ), + title: const Text('Strict Changes (Word Challenge)'), subtitle: const Text( 'Requires 15-word typing challenge before lax changes', - style: TextStyle(color: Colors.white54, fontSize: 13), + style: TextStyle(fontSize: 13), ), value: settings.requireWordChallenge, onChanged: (v) => settings.setRequireWordChallenge(v), @@ -268,8 +239,6 @@ class _DistractionSettingsPage extends StatelessWidget { } } -/// Stateful slider tile that shows a friction dialog when the user moves the -/// slider to a value greater than the current persisted value. class _FrictionSliderTile extends StatefulWidget { final String title; final String subtitle; @@ -320,13 +289,10 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> { crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( - title: Text( - widget.title, - style: const TextStyle(color: Colors.white), - ), + title: Text(widget.title), subtitle: Text( '${_draftValue.toInt()} min', - style: const TextStyle(color: Colors.white70, fontSize: 13), + style: const TextStyle(fontSize: 13), ), trailing: _pendingConfirm ? Row( @@ -339,10 +305,7 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> { _pendingConfirm = false; }); }, - child: const Text( - 'Cancel', - style: TextStyle(color: Colors.white38), - ), + child: const Text('Cancel'), ), ElevatedButton( onPressed: () async { @@ -413,19 +376,10 @@ class _ExtrasSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { final settings = context.watch(); - final allTabs = [ - 'Home', - 'Search', - 'Create', - 'Notifications', - 'Reels', - 'Profile', - ]; + final isDark = settings.isDarkMode; return Scaffold( - backgroundColor: Colors.black, appBar: AppBar( - backgroundColor: Colors.black, title: const Text('Extras', style: TextStyle(fontSize: 17)), leading: IconButton( icon: const Icon(Icons.arrow_back_ios_new, size: 18), @@ -435,59 +389,48 @@ class _ExtrasSettingsPage extends StatelessWidget { body: ListView( children: [ const _SettingsSectionHeader(title: 'EXPERIMENT'), - SwitchListTile( - title: const Text( - 'Ghost Mode', - style: TextStyle(color: Colors.white), + ListTile( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const _GhostModeSettingsPage()), ), - subtitle: const Text( - 'Hides "typing..." and "seen" status in DMs', - style: TextStyle(color: Colors.white54, fontSize: 13), + leading: Icon( + Icons.visibility_off_outlined, + color: isDark ? Colors.white70 : Colors.black87, ), - value: settings.ghostMode, - onChanged: (v) => settings.setGhostMode(v), - activeThumbColor: Colors.blue, + title: const Text('Ghost Mode'), + subtitle: Text( + settings.anyGhostModeEnabled + ? 'Active β€” some receipts are hidden' + : 'Disabled', + style: TextStyle( + color: settings.anyGhostModeEnabled + ? Colors.blue + : (isDark ? Colors.white38 : Colors.black38), + fontSize: 13, + ), + ), + trailing: const Icon(Icons.chevron_right), ), SwitchListTile( - title: const Text( - 'Enable Text Selection', - style: TextStyle(color: Colors.white), - ), + title: const Text('Enable Text Selection'), subtitle: const Text( 'Allows copying text from posts and captions', - style: TextStyle(color: Colors.white54, fontSize: 13), + style: TextStyle(fontSize: 13), ), value: settings.enableTextSelection, onChanged: (v) => settings.setEnableTextSelection(v), activeThumbColor: Colors.blue, ), - const _SettingsSectionHeader(title: 'BOTTOM BAR'), + const SizedBox(height: 24), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Wrap( - spacing: 8, - children: allTabs.map((tab) { - final isEnabled = settings.enabledTabs.contains(tab); - return FilterChip( - label: Text(tab), - selected: isEnabled, - onSelected: (_) => settings.toggleTab(tab), - backgroundColor: Colors.white10, - selectedColor: Colors.blue.withValues(alpha: 0.3), - checkmarkColor: Colors.blue, - labelStyle: TextStyle( - color: isEnabled ? Colors.blue : Colors.white60, - fontSize: 12, - ), - ); - }).toList(), - ), - ), - const Padding( - padding: EdgeInsets.fromLTRB(16, 4, 16, 16), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - 'Toggle tabs to customize your navigation bar. At least one tab must be enabled.', - style: TextStyle(color: Colors.white24, fontSize: 11), + 'Experimental features: Some features may break if Instagram updates their website.', + style: TextStyle( + color: isDark ? Colors.white24 : Colors.black26, + fontSize: 11, + ), ), ), ], @@ -516,3 +459,243 @@ class _SettingsSectionHeader extends StatelessWidget { ); } } + +class _GhostModeSettingsPage extends StatelessWidget { + const _GhostModeSettingsPage(); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final isDark = settings.isDarkMode; + + return Scaffold( + appBar: AppBar( + title: const Text('Ghost Mode', style: TextStyle(fontSize: 17)), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 18), + onPressed: () => Navigator.pop(context), + ), + ), + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + 'Control which activity receipts are hidden from other users. ', + style: TextStyle( + color: isDark ? Colors.white38 : Colors.black45, + fontSize: 12, + height: 1.4, + ), + ), + ), + const _SettingsSectionHeader(title: 'MESSAGING'), + SwitchListTile( + secondary: Icon( + Icons.keyboard_outlined, + color: isDark ? Colors.white54 : Colors.black54, + ), + title: const Text('Hide typing indicator'), + subtitle: Text( + "Others won't see the 'typing...' status when you write a message", + style: TextStyle( + color: isDark ? Colors.white38 : Colors.black45, + fontSize: 12, + ), + ), + value: settings.ghostTyping, + onChanged: (v) => settings.setGhostTyping(v), + activeThumbColor: Colors.blue, + ), + Stack( + children: [ + AbsorbPointer( + child: Opacity( + opacity: 0.5, + child: SwitchListTile( + secondary: Icon( + Icons.done_all_rounded, + color: isDark ? Colors.white54 : Colors.black54, + ), + title: const Text('Hide seen status'), + subtitle: Text( + "Others won't see when you've read their DMs", + style: TextStyle( + color: isDark ? Colors.white38 : Colors.black45, + fontSize: 12, + ), + ), + value: settings.ghostSeen, + onChanged: (v) => settings.setGhostSeen(v), + activeThumbColor: Colors.blue, + ), + ), + ), + Positioned( + right: 16, + top: 0, + bottom: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue, width: 0.5), + ), + child: const Text( + 'COMING SOON', + style: TextStyle( + color: Colors.blue, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ), + SwitchListTile( + secondary: Icon( + Icons.image_outlined, + color: isDark ? Colors.white54 : Colors.black54, + ), + title: const Text('Hide DM photo seen status'), + subtitle: Text( + 'Prevents Instagram from marking photos/videos in DMs as viewed', + style: TextStyle( + color: isDark ? Colors.white38 : Colors.black45, + fontSize: 12, + ), + ), + value: settings.ghostDmPhotos, + onChanged: (v) => settings.setGhostDmPhotos(v), + activeThumbColor: Colors.blue, + ), + const _SettingsSectionHeader(title: 'STORIES'), + SwitchListTile( + secondary: Icon( + Icons.auto_stories_outlined, + color: isDark ? Colors.white54 : Colors.black54, + ), + title: const Text('Story ghost mode'), + subtitle: Text( + 'Watch stories without appearing in the viewer list', + style: TextStyle( + color: isDark ? Colors.white38 : Colors.black45, + fontSize: 12, + ), + ), + value: settings.ghostStories, + onChanged: (v) => settings.setGhostStories(v), + activeThumbColor: Colors.blue, + ), + const SizedBox(height: 32), + ], + ), + ); + } +} + +class _NotificationSettingsPage extends StatelessWidget { + const _NotificationSettingsPage(); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final isDark = settings.isDarkMode; + + return Scaffold( + appBar: AppBar( + title: const Text('Notifications', style: TextStyle(fontSize: 17)), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 18), + onPressed: () => Navigator.pop(context), + ), + ), + body: ListView( + children: [ + Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Column( + children: [ + const Row( + children: [ + Icon( + Icons.info_outline, + color: Colors.blueAccent, + size: 20, + ), + SizedBox(width: 12), + Text( + 'Important Note', + style: TextStyle( + color: Colors.blueAccent, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + 'FocusGram monitors your session locally. For notifications to work, the app must be running in the background (minimized). If you force-close or swipe away the app from your task switcher, notifications will stop until you reopen it.', + style: TextStyle( + color: isDark ? Colors.white70 : Colors.black87, + fontSize: 13, + height: 1.4, + ), + ), + ], + ), + ), + SwitchListTile( + secondary: const Icon(Icons.mail_outline, color: Colors.blueAccent), + title: const Text('Direct Messages'), + subtitle: const Text( + 'Notify when you receive a new DM', + style: TextStyle(fontSize: 13), + ), + value: settings.notifyDMs, + onChanged: (v) => settings.setNotifyDMs(v), + activeThumbColor: Colors.blue, + ), + SwitchListTile( + secondary: const Icon( + Icons.favorite_border, + color: Colors.blueAccent, + ), + title: const Text('General Activity'), + subtitle: const Text( + 'Likes, mentions, and other interactions', + style: TextStyle(fontSize: 13), + ), + value: settings.notifyActivity, + onChanged: (v) => settings.setNotifyActivity(v), + activeThumbColor: Colors.blue, + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Note: Push notifications are generated by the app local service by monitoring the web sessions. This does not rely on Instagram servers sending notifications to your device.', + style: TextStyle( + color: isDark ? Colors.white24 : Colors.black26, + fontSize: 11, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/focusgram_router.dart b/lib/services/focusgram_router.dart new file mode 100644 index 0000000..ea0b418 --- /dev/null +++ b/lib/services/focusgram_router.dart @@ -0,0 +1,13 @@ +import 'package:flutter/foundation.dart'; + +/// Lightweight global router for cross-widget navigation signals. +/// Used to allow the Settings page to trigger WebView navigations without +/// requiring a BuildContext reference to MainWebViewPage. +class FocusGramRouter { + FocusGramRouter._(); + + /// When this value is non-null, [MainWebViewPage] will load the URL + /// in the WebView and clear this value. Settings page sets this to + /// trigger in-app navigation (e.g. Instagram Settings). + static final pendingUrl = ValueNotifier(null); +} diff --git a/lib/services/injection_controller.dart b/lib/services/injection_controller.dart index 8ace09d..dc4e71c 100644 --- a/lib/services/injection_controller.dart +++ b/lib/services/injection_controller.dart @@ -1,207 +1,261 @@ -/// Controller for injecting custom JS and CSS into the WebView. -/// Uses a combination of static strings and dynamic builders to: -/// - Hide native navigation elements. -/// - Inject FocusGram branding into the native header. -/// - Implement "Ghost Mode" (stealth features). -/// - Manage Reels/Explore distractions. +// ============================================================================ +// FocusGram β€” InjectionController +// ============================================================================ +// +// Builds all JavaScript and CSS payloads injected into the Instagram WebView. +// +// ── Ghost Mode Design ──────────────────────────────────────────────────────── +// +// Instead of blocking exact URLs (brittle β€” Instagram renames paths constantly), +// we block by SEMANTIC KEYWORD GROUPS. A request is silenced if its URL contains +// ANY keyword from the relevant group. +// +// Ghost Mode Semantic Groups (last verified: 2025-02) +// ──────────────────────────────────────────────────── +// seenKeywords β€” story/DM seen receipts (any endpoint Instagram uses to +// tell others you read/watched something) +// typingKeywords β€” typing indicator REST calls + WS text frames +// liveKeywords β€” live viewer heartbeat / join_request (presence on streams) +// photoKeywords β€” disappearing / view-once DM photo seen receipts +// +// Adding new endpoints in the future: just append a keyword to the right group +// in _ghostGroups below β€” no other code needs to change. +// +// ── Confirmed endpoint map ─────────────────────────────────────────────────── +// /api/v1/media/seen/ β€” story seen v1 (covered by "media/seen") +// /api/v2/media/seen/ β€” story seen v2 (covered by "media/seen") +// /stories/reel/seen β€” web story seen (covered by "reel/seen") +// /api/v1/stories/reel/mark_seen/ β€” story mark (covered by "mark_seen") +// /direct_v2/threads/…/seen/ β€” DM message read (covered by "/seen") +// /api/v1/direct_v2/set_reel_seen/ β€” DM story (covered by "reel_seen") +// /api/v1/direct_v2/mark_visual_item_seen/ β€” disappearing photos +// /api/v1/live/…/heartbeat_and_get_viewer_count/ β€” live presence +// /api/v1/live/…/join_request/ β€” live join +// WS text frames with "typing", "direct_v2/typing", "activity_status" +// +// ============================================================================ + +/// Central hub for all JavaScript and CSS injected into the Instagram WebView. class InjectionController { - /// The requested iOS 18.6 User Agent for Instagram App feel. + // ── User Agent ────────────────────────────────────────────────────────────── + + /// iOS UA ensures Instagram serves the full mobile UI (Reels, Stories, DMs). + /// Without spoofing, instagram.com returns a stripped desktop-lite shell. static const String iOSUserAgent = - 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22G86 [FBAN/FBIOS;FBAV/531.0.0.35.77;FBBV/792629356;FBDV/iPhone17,2;FBMD/iPhone;FBSN/iOS;FBSV/18.6;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5;FBRV/0;IABMV/1]'; + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + 'Mobile/22G86 [FBAN/FBIOS;FBAV/531.0.0.35.77;FBBV/792629356;' + 'FBDV/iPhone17,2;FBMD/iPhone;FBSN/iOS;FBSV/18.6;FBSS/3;' + 'FBID/phone;FBLC/en_US;FBOP/5;FBRV/0;IABMV/1]'; - // ── CSS & JS injection ────────────────────────────────────────────────────── + // ── Ghost Mode keyword groups ──────────────────────────────────────────────── - /// CSS to fix UI nuances like tap highlights. + /// Semantic groups used by [buildGhostModeJS]. + /// + /// Each group is a list of URL substrings. A network request is suppressed + /// if its URL contains ANY substring in the enabled groups. + /// + /// To add future endpoints: append keywords here β€” nothing else changes. + static const Map> _ghostGroups = { + // Any URL that records you having seen/read something + 'seen': ['/seen', '/mark_seen', 'reel_seen', 'reel/seen', 'media/seen'], + // Typing indicator (REST + WebSocket text frames) + 'typing': ['set_typing_status', '/typing', 'activity_status'], + // Live stream viewer join / heartbeat (you appear in viewer list) + 'live': ['/live/'], + // Disappearing / view-once DM photos + 'dmPhotos': ['visual_item_seen'], + }; + + // ── CSS ───────────────────────────────────────────────────────────────────── + + /// Base UI polish β€” hides scrollbars and Instagram's nav tab-bar. + /// Important: we must NOT hide [role="tablist"] inside dialogs/modals, + /// because Instagram's comment input sheet also uses that role and the + /// CSS would paint a grey overlay on top of the typing area. static const String _globalUIFixesCSS = ''' - * { - -webkit-tap-highlight-color: transparent !important; - outline: none !important; - } - /* Hide all scrollbars */ - ::-webkit-scrollbar { - display: none !important; - width: 0 !important; - height: 0 !important; - } + ::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; } * { -ms-overflow-style: none !important; scrollbar-width: none !important; + -webkit-tap-highlight-color: transparent !important; } - '''; - - /// CSS to disable text selection globally. - static const String _disableSelectionCSS = ''' - * { - -webkit-user-select: none !important; - user-select: none !important; - } - '''; - - /// Ghost Mode JS: Intercepts network calls to block seen/typing receipts. - static const String _ghostModeJS = ''' - (function() { - const blockedUrls = [ - '/api/v1/direct_v2/set_reel_seen/', - '/api/v1/direct_v2/threads/set_typing_status/', - '/api/v1/stories/reel/seen/', - '/api/v1/direct_v2/mark_visual_item_seen/' - ]; - - // Proxy fetch - const originalFetch = window.fetch; - window.fetch = function(url, options) { - if (typeof url === 'string') { - if (blockedUrls.some(u => url.includes(u))) { - return Promise.resolve(new Response(null, { status: 204 })); - } - } - return originalFetch.apply(this, arguments); - }; - - // Proxy XHR - const originalOpen = XMLHttpRequest.prototype.open; - XMLHttpRequest.prototype.open = function(method, url) { - this._blocked = blockedUrls.some(u => url.includes(u)); - return originalOpen.apply(this, arguments); - }; - const originalSend = XMLHttpRequest.prototype.send; - XMLHttpRequest.prototype.send = function() { - if (this._blocked) return; - return originalSend.apply(this, arguments); - }; - })(); - '''; - - /// Branding JS: Replaces Instagram logo with FocusGram while keeping icons. - static const String _brandingJS = ''' - (function() { - function applyBranding() { - const igLogo = document.querySelector('svg[aria-label="Instagram"], svg[aria-label="Direct"]'); - if (igLogo && !igLogo.dataset.focusgrammed) { - const container = igLogo.parentElement; - if (container) { - igLogo.style.display = 'none'; - igLogo.dataset.focusgrammed = 'true'; - - const brandText = document.createElement('span'); - brandText.innerText = 'FocusGram'; - brandText.style.fontFamily = '"Grand Hotel", cursive'; - brandText.style.fontSize = '24px'; - brandText.style.color = 'white'; - brandText.style.marginLeft = '8px'; - brandText.style.verticalAlign = 'middle'; - - container.appendChild(brandText); - } - } - } - applyBranding(); - const observer = new MutationObserver(applyBranding); - observer.observe(document.body, { childList: true, subtree: true }); - })(); - '''; - - /// Robust CSS that hides Instagram's native bottom nav bar. - static const String _hideInstagramNavCSS = ''' - /* Hide bottom nav but keep search header */ - div[role="tablist"], footer nav, ._acbl, ._aa4b { - display: none !important; - visibility: hidden !important; - height: 0 !important; - overflow: hidden !important; - pointer-events: none !important; - } - /* Only hide top nav if not on search page */ - body:not([path*="/explore/search/"]) nav[role="navigation"], - body:not([path*="/explore/search/"]) section nav { + /* Only hide the PRIMARY nav tablist (bottom bar), not tablist inside dialogs */ + body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]), + [aria-label="Direct"] header { display: none !important; - } - body, #react-root, main { - padding-bottom: 0 !important; - margin-bottom: 0 !important; + visibility: hidden !important; + height: 0 !important; + pointer-events: none !important; } '''; - /// CSS to hide Reel-related elements everywhere. - static const String _hideReelsCSS = ''' - a[href*="/reel/"], a[href*="/reels"], [aria-label*="Reel"], [aria-label*="Reels"], - div[data-media-type="2"], [aria-label="Reels"], svg[aria-label="Reels"] { + /// Blurs images/videos in the home feed AND on Explore. + /// Activated via the body[path] attribute written by [_trackPathJS]. + static const String _blurHomeFeedAndExploreCSS = ''' + body[path="/"] article img, + body[path="/"] article video, + body[path^="/explore"] img, + body[path^="/explore"] video, + body[path="/explore/"] img, + body[path="/explore/"] video { + filter: blur(20px) !important; + transition: filter 0.15s ease !important; + } + body[path="/"] article img:hover, + body[path="/"] article video:hover, + body[path^="/explore"] img:hover, + body[path^="/explore"] video:hover { + filter: blur(20px) !important; + } + '''; + + /// Prevents text selection to keep the app feeling native. + static const String _disableSelectionCSS = ''' + * { -webkit-user-select: none !important; user-select: none !important; } + '''; + + /// Hides reel posts in the home feed when no Reel Session is active. + /// The Reels nav tab is NOT hidden β€” Flutter intercepts that navigation. + static const String _hideReelsFeedContentCSS = ''' + a[href*="/reel/"], + div[data-media-type="2"] { display: none !important; visibility: hidden !important; - pointer-events: none !important; } '''; - /// CSS that adds bottom padding so feed content doesn't hide behind our bar. - static const String _bottomPaddingCSS = ''' - body, #react-root > div, [role="presentation"] > div { - padding-bottom: 72px !important; - } - div[style*="bottom: 0px"], div[style*="bottom: 0"], form[method="POST"] { - padding-bottom: 72px !important; - } - div[role="main"] div[style*="position: fixed"] { - bottom: 72px !important; - } - '''; - - /// CSS to blur Explore feed posts/reels. - static const String _blurExploreCSS = ''' - main[role="main"] section > div > div:not(:first-child) a img, - main[role="main"] section > div > div:not(:first-child) video, - main[role="main"] article img, main[role="main"] article video, - ._aagv img, ._aagv video { - filter: blur(12px) !important; - pointer-events: none !important; - } - '''; + // _blurExploreCSS removed β€” replaced by _blurHomeFeedAndExploreCSS above. + /// Blurs reel thumbnail images shown in the feed. static const String _blurReelsCSS = ''' - a[href*="/reel/"] img, a[href*="/reels"] img { - filter: blur(12px) !important; - } + a[href*="/reel/"] img { filter: blur(12px) !important; } '''; - /// Auto-dismiss "Open in App" banner. + // ── JavaScript helpers ─────────────────────────────────────────────────────── + + /// Removes the "Open in App" nag banner. static const String _dismissAppBannerJS = ''' - (function dismissBanners() { - const selectors = ['[id*="app-banner"]', '[class*="app-banner"]', 'div[role="dialog"][aria-label*="app"]']; - selectors.forEach(sel => document.querySelectorAll(sel).forEach(el => el.remove())); + (function fgDismissBanner() { + ['[id*="app-banner"]','[class*="app-banner"]', + 'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]'] + .forEach(s => document.querySelectorAll(s).forEach(el => el.remove())); })(); '''; - /// Periodic remover for bottom nav. - static const String _periodicNavRemoverJS = ''' - (function periodicNavRemove() { - function removeNav() { - document.querySelectorAll('div[role="tablist"], nav[role="navigation"], footer nav').forEach(el => { - el.style.cssText += ';display:none!important;height:0!important;'; - }); + /// Replaces ONLY the Instagram wordmark SVG with "FocusGram" brand text. + /// Specifically targets the top-bar logo SVG (aria-label="Instagram") while + /// explicitly excluding SVG icons inside nav/tablist (home, notifications, + /// create, reels, profile icons). + static const String _brandingJS = r''' + (function fgBranding() { + // Only the wordmark: SVG with aria-label="Instagram" that is NOT inside + // a [role="tablist"] (bottom nav) or a [role="navigation"] (nav bar). + // Also targets the ._ac83 class which Instagram uses for its top wordmark. + const WORDMARK_SEL = [ + 'svg[aria-label="Instagram"]', + '._ac83 svg[aria-label="Instagram"]', + 'h1[role="presentation"] svg', + ]; + const STYLE = + 'font-family:"Grand Hotel",cursive;font-size:26px;color:#fff;' + + 'vertical-align:middle;cursor:default;letter-spacing:.5px;display:inline-block;'; + + function isNavIcon(el) { + // Exclude any SVG that lives inside a tablist, nav, or link with + // non-home/non-root href (these are functional icons, not the wordmark). + if (el.closest('[role="tablist"]')) return true; + if (el.closest('[role="navigation"]')) return true; + // The wordmark is always at the TOP of the page in a header/banner + const header = el.closest('header, [role="banner"], [role="main"]'); + if (!header && el.closest('[role="button"]')) return true; + // If the SVG has a meaningful role (img presenting an action icon), skip it + const role = el.getAttribute('role'); + if (role && role !== 'img') return true; + // If the parent goes somewhere other than "/" it is a nav link + const anchor = el.closest('a'); + if (anchor) { + const href = anchor.getAttribute('href') || ''; + if (href && href !== '/' && !href.startsWith('/?')) return true; + } + return false; } - removeNav(); - setInterval(removeNav, 500); + + function apply() { + WORDMARK_SEL.forEach(sel => document.querySelectorAll(sel).forEach(logo => { + if (logo.dataset.fgBranded) return; + if (isNavIcon(logo)) return; + logo.dataset.fgBranded = 'true'; + const span = Object.assign(document.createElement('span'), + { textContent: 'FocusGram' }); + span.style.cssText = STYLE; + logo.style.display = 'none'; + logo.parentNode.insertBefore(span, logo.nextSibling); + })); + } + apply(); + new MutationObserver(apply) + .observe(document.documentElement, { childList: true, subtree: true }); })(); '''; - /// MutationObserver that continuously re-applies CSS. + /// Intercepts clicks on /reels/ links when no session is active and redirects + /// to a recognisable URL so Flutter's NavigationDelegate can catch and block it. + /// + /// Without this, fast SPA clicks bypass the NavigationDelegate entirely. + static const String _strictReelsBlockJS = r''' + (function fgReelsBlock() { + if (window.__fgReelsBlockPatched) return; + window.__fgReelsBlockPatched = true; + document.addEventListener('click', e => { + if (window.__focusgramSessionActive) return; + const a = e.target && e.target.closest('a[href*="/reels/"]'); + if (!a) return; + e.preventDefault(); + e.stopPropagation(); + window.location.href = '/reels/?fg=blocked'; + }, true); + })(); + '''; + + /// SPA route tracker: writes `body[path]` and notifies Flutter of path changes + /// via `FocusGramPathChannel` so reels can be blocked on SPA navigation. + static const String _trackPathJS = ''' + (function fgTrackPath() { + if (window.__fgPathTrackerRunning) return; + window.__fgPathTrackerRunning = true; + let last = window.location.pathname; + function check() { + const p = window.location.pathname; + if (p !== last) { + last = p; + if (document.body) document.body.setAttribute('path', p); + if (window.FocusGramPathChannel) window.FocusGramPathChannel.postMessage(p); + } + } + if (document.body) document.body.setAttribute('path', last); + setInterval(check, 500); + })(); + '''; + + /// Injects a persistent `style` element and keeps it alive across SPA route + /// changes by watching for it being removed from `head`. static String _buildMutationObserver(String cssContent) => ''' - (function applyFocusGramStyles() { - const STYLE_ID = 'focusgram-injected-style'; - function injectCSS() { - let el = document.getElementById(STYLE_ID); + (function fgApplyStyles() { + const ID = 'focusgram-style'; + function inject() { + let el = document.getElementById(ID); if (!el) { el = document.createElement('style'); - el.id = STYLE_ID; - document.head.appendChild(el); + el.id = ID; + (document.head || document.documentElement).appendChild(el); } el.textContent = ${_escapeJsString(cssContent)}; } - injectCSS(); - const observer = new MutationObserver(() => { - if (!document.getElementById(STYLE_ID)) injectCSS(); - }); - observer.observe(document.documentElement, { childList: true, subtree: true }); + inject(); + new MutationObserver(() => { if (!document.getElementById(ID)) inject(); }) + .observe(document.documentElement, { childList: true, subtree: true }); })(); '''; @@ -210,110 +264,537 @@ class InjectionController { return '`$escaped`'; } - // ── Navigation helpers ────────────────────────────────────────────────────── + // ── Navigation helpers ─────────────────────────────────────────────────────── + /// Returns JS that navigates to [path] only when not already on it. static String softNavigateJS(String path) => ''' (function() { - const target = ${_escapeJsString(path)}; - if (window.location.pathname !== target) { - window.location.href = target; - } + const t = ${_escapeJsString(path)}; + if (window.location.pathname !== t) window.location.href = t; })(); '''; - static const String clickCreateButtonJS = ''' - (function() { - const btn = document.querySelector('[aria-label="New post"], [aria-label="Create"]'); - if (btn) btn.closest('a, button') ? btn.closest('a, button').click() : btn.click(); - })(); - '''; - - /// Hijacks the Web Notification API to bridge Instagram notifications to native. - static String get notificationBridgeJS => """ - (function() { - const NativeNotification = window.Notification; - if (!NativeNotification) return; - - window.Notification = function(title, options) { - const body = (options && options.body) ? options.body : ""; - - // Pass to Flutter - if (window.FocusGramNotificationChannel) { - window.FocusGramNotificationChannel.postMessage(title + ": " + body); - } - - return new NativeNotification(title, options); - }; - - window.Notification.permission = "granted"; - window.Notification.requestPermission = function() { - return Promise.resolve("granted"); - }; - })(); - """; - - /// MutationObserver for Reel scroll locking. - static const String reelsMutationObserverJS = ''' - (function() { - function lockReelScroll(reelContainer) { - if (reelContainer.dataset.scrollLocked) return; - reelContainer.dataset.scrollLocked = 'true'; - let startY = 0; - reelContainer.addEventListener('touchstart', (e) => startY = e.touches[0].clientY, { passive: true }); - reelContainer.addEventListener('touchmove', (e) => { - if (window.__focusgramSessionActive === true) return; - const deltaY = e.touches[0].clientY - startY; - if (deltaY < -10 && e.cancelable) { - e.preventDefault(); - e.stopPropagation(); - } - }, { passive: false }); - } - const observer = new MutationObserver(() => { - document.querySelectorAll('[class*="ReelsVideoPlayer"], video').forEach((el) => { - if (el.tagName === 'VIDEO' && el.closest('article')) return; - lockReelScroll(el); - if (el.tagName === 'VIDEO' && el.parentElement) lockReelScroll(el.parentElement); - }); - }); - observer.observe(document.body, { childList: true, subtree: true }); - })(); - '''; + // ── Session state ──────────────────────────────────────────────────────────── + /// Writes the current session-active flag into the WebView global scope. + /// All injected scripts (Ghost Mode, scroll lock) read this flag. static String buildSessionStateJS(bool active) => 'window.__focusgramSessionActive = $active;'; - /// Full injection JS to run on page load. + // ── Ghost Mode ─────────────────────────────────────────────────────────────── + + /// Returns all URL keywords that should be blocked for the given feature flags. + /// + /// Exposed as a separate method so unit tests can verify keyword selection + /// independently of the full JS string. + static List resolveBlockedKeywords({ + required bool typingIndicator, + required bool seenStatus, + required bool stories, + required bool dmPhotos, + }) { + final out = []; + if (seenStatus) out.addAll(_ghostGroups['seen']!); + if (typingIndicator) out.addAll(_ghostGroups['typing']!); + if (stories) out.addAll(_ghostGroups['live']!); + if (dmPhotos) out.addAll(_ghostGroups['dmPhotos']!); + return out; + } + + /// Returns all WebSocket text-frame keywords to drop for the given flags. + static List resolveWsBlockedKeywords({ + required bool typingIndicator, + }) { + if (!typingIndicator) return const []; + return List.unmodifiable(_ghostGroups['typing']!); + } + + /// Builds JavaScript that intercepts fetch, XHR, WebSocket, and sendBeacon + /// traffic to suppress ALL activity receipts (seen, typing, live, DM photos). + /// + /// All blocked requests return `{"status":"ok"}` with HTTP 200 so Instagram + /// does not retry or display an error. + /// + /// See [resolveBlockedKeywords] for the URL-keyword logic. + static String buildGhostModeJS({ + required bool typingIndicator, + required bool seenStatus, + required bool stories, + required bool dmPhotos, + }) { + if (!typingIndicator && !seenStatus && !stories && !dmPhotos) return ''; + + final blocked = resolveBlockedKeywords( + typingIndicator: typingIndicator, + seenStatus: seenStatus, + stories: stories, + dmPhotos: dmPhotos, + ); + final wsBlocked = resolveWsBlockedKeywords( + typingIndicator: typingIndicator, + ); + + final urlsJson = blocked.map((u) => '"$u"').join(', '); + final wsJson = wsBlocked.map((u) => '"$u"').join(', '); + + return ''' + (function fgGhostMode() { + if (window.__fgGhostModeDone) return; + window.__fgGhostModeDone = true; + + // URL substrings β€” any request whose URL contains one of these is silenced. + const BLOCKED = [$urlsJson]; + // WebSocket text-frame keywords to drop (MQTT typing/presence). + const WS_KEYS = [$wsJson]; + + function shouldBlock(url) { + return typeof url === 'string' && BLOCKED.some(k => url.includes(k)); + } + + function isDmVideoLocked(url) { + if (typeof url !== 'string') return false; + if (!url.includes('.mp4') && !url.includes('/v/t') && !url.includes('cdninstagram') && !url.includes('.dash')) return false; + return window.__fgDmReelAlreadyLoaded === true; + } + + // ── fetch ────────────────────────────────────────────────────────────── + const _oFetch = window.__fgOrigFetch || window.fetch; + window.__fgOrigFetch = _oFetch; + window.__fgGhostFetch = function(resource, init) { + const url = typeof resource === 'string' ? resource : (resource && resource.url) || ''; + // Ghost mode: block seen/typing receipts + if (shouldBlock(url)) + return Promise.resolve(new Response('{"status":"ok"}', + { status: 200, headers: { 'Content-Type': 'application/json' } })); + // DM isolation: block additional video segments after first reel loaded + if (isDmVideoLocked(url)) + return Promise.resolve(new Response('', { status: 200 })); + return _oFetch.apply(this, arguments); + }; + window.fetch = window.__fgGhostFetch; + + // ── sendBeacon ───────────────────────────────────────────────────────── + if (navigator.sendBeacon && !window.__fgBeaconPatched) { + window.__fgBeaconPatched = true; + const _oBeacon = navigator.sendBeacon.bind(navigator); + navigator.sendBeacon = function(url, data) { + if (shouldBlock(url)) return true; + return _oBeacon(url, data); + }; + } + + // ── XHR ──────────────────────────────────────────────────────────────── + const _oOpen = window.__fgOrigXhrOpen || XMLHttpRequest.prototype.open; + const _oSend = window.__fgOrigXhrSend || XMLHttpRequest.prototype.send; + window.__fgOrigXhrOpen = _oOpen; + window.__fgOrigXhrSend = _oSend; + XMLHttpRequest.prototype.open = function(m, url) { + this._fgUrl = url; + this._fgBlock = shouldBlock(url); + return _oOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + if (this._fgBlock) { + Object.defineProperty(this, 'readyState', { get: () => 4, configurable: true }); + Object.defineProperty(this, 'status', { get: () => 200, configurable: true }); + Object.defineProperty(this, 'responseText', { get: () => '{"status":"ok"}', configurable: true }); + Object.defineProperty(this, 'response', { get: () => '{"status":"ok"}', configurable: true }); + setTimeout(() => { + try { if (this.onreadystatechange) this.onreadystatechange(); } catch(_) {} + try { if (this.onload) this.onload(); } catch(_) {} + }, 0); + return; + } + // DM isolation: block additional video XHR fetches after first reel loaded + if (this._fgUrl && isDmVideoLocked(this._fgUrl)) { + setTimeout(() => { try { this.onload?.(); } catch(_) {} }, 0); + return; + } + return _oSend.apply(this, arguments); + }; + + // ── WebSocket β€” block text AND binary frames ─────────────────────────── + if (!window.__fgWsGhostDone) { + window.__fgWsGhostDone = true; + const _OWS = window.WebSocket; + const ALL_SEEN = [$urlsJson]; + function containsKeyword(data) { + if (typeof data === 'string') return ALL_SEEN.some(k => data.includes(k)); + try { + let bytes; + if (data instanceof ArrayBuffer) bytes = new Uint8Array(data); + else if (data instanceof Uint8Array) bytes = data; + else return false; + const text = String.fromCharCode.apply(null, bytes); + return ALL_SEEN.some(k => text.includes(k)); + } catch(_) { return false; } + } + function FgWS(url, proto) { + const ws = proto != null ? new _OWS(url, proto) : new _OWS(url); + const _send = ws.send.bind(ws); + ws.send = function(data) { + if (containsKeyword(data)) return; + return _send(data); + }; + return ws; + } + FgWS.prototype = _OWS.prototype; + ['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(k => FgWS[k] = _OWS[k]); + window.WebSocket = FgWS; + } + + // Reapply every 3 s in case Instagram replaces window.fetch + if (!window.__fgGhostReapplyInterval) { + window.__fgGhostReapplyInterval = setInterval(() => { + if (window.fetch !== window.__fgGhostFetch && window.__fgOrigFetch) + window.fetch = window.__fgGhostFetch; + }, 3000); + } + })(); + '''; + } + + // ── Theme Detector ─────────────────────────────────────────────────────────── + + /// Detects Instagram's current theme (dark/light) and notifies Flutter. + static const String _themeDetectorJS = r''' + (function fgThemeSync() { + if (window.__fgThemeSyncRunning) return; + window.__fgThemeSyncRunning = true; + + function getTheme() { + try { + // 1. Check Instagram's specific classes + const h = document.documentElement; + if (h.classList.contains('style-dark')) return 'dark'; + if (h.classList.contains('style-light')) return 'light'; + + // 2. Check body background color + const bg = window.getComputedStyle(document.body).backgroundColor; + const rgb = bg.match(/\d+/g); + if (rgb && rgb.length >= 3) { + const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255; + return luminance < 0.5 ? 'dark' : 'light'; + } + } catch(_) {} + return 'dark'; // Fallback + } + + let last = ''; + function check() { + const current = getTheme(); + if (current !== last) { + last = current; + if (window.FocusGramThemeChannel) { + window.FocusGramThemeChannel.postMessage(current); + } + } + } + setInterval(check, 1500); + check(); + })(); + '''; + + // ── Reel scroll lock ───────────────────────────────────────────────────────── + + /// Prevents swipe-to-next-reel in the isolated DM reel player. + /// + /// Lock is active when: + /// `window.__focusgramIsolatedPlayer === true` (DM overlay) + /// OR `window.__focusgramSessionActive === false` (no session) + /// + /// Allow-list (these are never blocked): + /// β€’ buttons, anchors, [role=button], aria elements + /// β€’ dialogs, menus, modals, sheets (comment box, emoji picker, share sheet) + /// β€’ keyboard input inside comment / text fields + /// Prevents swipe-to-next-reel in the isolated DM reel player. + /// + /// Uses a document-level capture-phase touchmove listener so it fires BEFORE + /// Instagram's scroll container can steal the gesture. The lock is active when + /// `window.__focusgramIsolatedPlayer === true` (single reel from DM), + /// OR `window.__focusgramSessionActive === false` (reels feed, no session). + /// + /// The isolated player flag is also maintained here from the path tracker + /// so it works for SPA navigations that don't trigger onPageFinished. + static const String reelsMutationObserverJS = r''' + (function fgReelLock() { + if (window.__fgReelLockRunning) return; + window.__fgReelLockRunning = true; + + const ALLOW_SEL = 'button,a,[role="button"],[aria-label],[aria-haspopup],input,textarea,span,h1,h2,h3'; + const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]'; + + function isLocked() { + const isDmReel = window.location.pathname.includes('/direct/') && + !!document.querySelector('[class*="ReelsVideoPlayer"]'); + return window.__focusgramIsolatedPlayer === true || + window.__focusgramSessionActive === false || + isDmReel; + } + + let sy = 0; + document.addEventListener('touchstart', e => { + sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0; + }, { capture: true, passive: true }); + + document.addEventListener('touchmove', e => { + if (!isLocked()) return; + // Allow vertical swipe if in a session and not on a DM/isolated path + if (window.__focusgramSessionActive === true && !window.location.pathname.includes('/direct/')) return; + + const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0; + if (Math.abs(dy) > 2) { + if (e.target && e.target.closest && (e.target.closest(ALLOW_SEL) || e.target.closest(MODAL_SEL))) return; + // Mark the first DM reel as loaded on first swipe attempt + if (window.location.pathname.includes('/direct/')) { + window.__fgDmReelAlreadyLoaded = true; + } + if (e.cancelable) e.preventDefault(); + e.stopPropagation(); + } + }, { capture: true, passive: false }); + + function block(e) { + if (!isLocked()) return; + if (e.target && e.target.closest && (e.target.closest(ALLOW_SEL) || e.target.closest(MODAL_SEL))) return; + if (e.cancelable) e.preventDefault(); + e.stopPropagation(); + } + document.addEventListener('wheel', block, { capture: true, passive: false }); + document.addEventListener('keydown', e => { + if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return; + if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return; + block(e); + }, { capture: true, passive: false }); + + const REEL_SEL = '[class*="ReelsVideoPlayer"], video'; + + function sync() { + const reels = document.querySelectorAll(REEL_SEL); + + if (window.location.pathname.includes('/direct/') && reels.length > 0) { + // Give the first reel 3.5 s to buffer before activating the DM lock + if (!window.__fgDmReelTimer) { + window.__fgDmReelTimer = setTimeout(() => { + if (document.querySelector(REEL_SEL)) { + window.__fgDmReelAlreadyLoaded = true; + } + window.__fgDmReelTimer = null; + }, 3500); + } + } + + if (reels.length === 0) { + if (window.__fgDmReelTimer) { + clearTimeout(window.__fgDmReelTimer); + window.__fgDmReelTimer = null; + } + window.__fgDmReelAlreadyLoaded = false; + } + } + + sync(); + new MutationObserver(ms => { + if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync(); + }).observe(document.body, { childList: true, subtree: true }); + + // Keep __focusgramIsolatedPlayer in sync with SPA navigations + if (!window.__fgIsolatedPlayerSync) { + window.__fgIsolatedPlayerSync = true; + let _lastPath = window.location.pathname; + setInterval(() => { + const p = window.location.pathname; + if (p === _lastPath) return; + _lastPath = p; + window.__focusgramIsolatedPlayer = + p.includes('/reel/') && !p.startsWith('/reels/'); + if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false; + }, 400); + } + })(); + '''; + + // ── Badge Monitor ──────────────────────────────────────────────────────────── + + /// Periodically checks Instagram's UI for unread counts (badges) on the Direct + /// and Notifications icons, as well as the page title. Sends an event to + /// Flutter whenever a new notification is detected. + static const String _badgeMonitorJS = r''' + (function fgBadgeMonitor() { + if (window.__fgBadgeMonitorRunning) return; + window.__fgBadgeMonitorRunning = true; + + let lastDmCount = 0; + let lastNotifCount = 0; + let lastTitleUnread = 0; + + function check() { + try { + // 1. Check Title for (N) indicator + const titleMatch = document.title.match(/\((\d+)\)/); + const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0; + + // 2. Scan for DM unread badge + const dmBadge = document.querySelector([ + 'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]', + 'a[href*="/direct/inbox/"] [style*="255, 48, 64"]', + 'a[href*="/direct/inbox/"] [aria-label*="unread"]', + 'div[role="button"][aria-label*="Direct"] [style*="255, 48, 64"]', + 'a[href*="/direct/inbox/"] svg[aria-label*="Direct"] + div', // New red dot sibling + 'a[href*="/direct/inbox/"] ._a9-v', // Modern common red badge class + ].join(',')); + const currentDmCount = dmBadge ? (parseInt(dmBadge.innerText) || 1) : 0; + + // 3. Scan for Notifications unread badge + const notifBadge = document.querySelector([ + 'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]', + 'a[href*="/notifications"] [style*="255, 48, 64"]', + 'a[href*="/notifications"] [aria-label*="unread"]' + ].join(',')); + const currentNotifCount = notifBadge ? (parseInt(notifBadge.innerText) || 1) : 0; + + if (currentDmCount > lastDmCount) { + window.FocusGramNotificationChannel?.postMessage('DM'); + } else if (currentNotifCount > lastNotifCount) { + window.FocusGramNotificationChannel?.postMessage('Activity'); + } else if (currentTitleUnread > lastTitleUnread && currentTitleUnread > (currentDmCount + currentNotifCount)) { + window.FocusGramNotificationChannel?.postMessage('Activity'); + } + + lastDmCount = currentDmCount; + lastNotifCount = currentNotifCount; + lastTitleUnread = currentTitleUnread; + } catch(_) {} + } + + // Initial check after some delay to let page settle + setTimeout(check, 2000); + setInterval(check, 3000); + })(); + '''; + + // ── Notification bridge ────────────────────────────────────────────────────── + + /// Forwards Web Notification events to the native Flutter channel. + static String get notificationBridgeJS => ''' + (function fgNotifBridge() { + if (!window.Notification || window.__fgNotifBridged) return; + window.__fgNotifBridged = true; + const _N = window.Notification; + window.Notification = function(title, opts) { + try { + if (window.FocusGramNotificationChannel) + window.FocusGramNotificationChannel + .postMessage(title + (opts && opts.body ? ': ' + opts.body : '')); + } catch(_) {} + return new _N(title, opts); + }; + window.Notification.permission = 'granted'; + window.Notification.requestPermission = () => Promise.resolve('granted'); + })(); + '''; + + // ── Link sanitization ──────────────────────────────────────────────────────── + + /// Strips tracking query params (igsh, utm_*, fbclid…) from all links and the + /// native Web Share API. Sanitised share URLs are routed to Flutter's share + /// channel instead. + static const String linkSanitizationJS = r''' + (function fgSanitize() { + if (window.__fgSanitizePatched) return; + window.__fgSanitizePatched = true; + const STRIP = [ + 'igsh','igshid','fbclid', + 'utm_source','utm_medium','utm_campaign','utm_term','utm_content', + 'ref','s','_branch_match_id','_branch_referrer', + ]; + function clean(raw) { + try { + const u = new URL(raw, location.origin); + STRIP.forEach(p => u.searchParams.delete(p)); + return u.toString(); + } catch(_) { return raw; } + } + if (navigator.share) { + const _s = navigator.share.bind(navigator); + navigator.share = function(d) { + const u = d && d.url ? clean(d.url) : null; + if (window.FocusGramShareChannel && u) { + window.FocusGramShareChannel.postMessage( + JSON.stringify({ url: u, title: (d && d.title) || '' })); + return Promise.resolve(); + } + return _s({ ...d, url: u || (d && d.url) }); + }; + } + document.addEventListener('click', e => { + const a = e.target && e.target.closest('a[href]'); + if (!a) return; + const href = a.getAttribute('href'); + if (!href || href.startsWith('#') || href.startsWith('javascript')) return; + try { + const u = new URL(href, location.origin); + if (STRIP.some(p => u.searchParams.has(p))) { + STRIP.forEach(p => u.searchParams.delete(p)); + a.href = u.toString(); + } + } catch(_) {} + }, true); + })(); + '''; + + // ── Main injection builder ─────────────────────────────────────────────────── + + /// Builds the complete JS payload for a page load or session-state change. + /// + /// Injection order matters (later scripts can depend on earlier ones): + /// 1. Session flag β€” other scripts read `__focusgramSessionActive` + /// 2. Path tracker β€” writes `body[path]` for CSS page targeting + /// 3. CSS observer β€” keeps `