[
  {
    "path": ".gitattributes",
    "content": "# Auto detect text files and perform LF normalization\n* text=auto\n"
  },
  {
    "path": ".gitignore",
    "content": "# Miscellaneous\n*.class\n*.log\n*.pyc\n*.swp\n.DS_Store\n.atom/\n.build/\n.buildlog/\n.history\n.svn/\n.swiftpm/\nmigrate_working_dir/\n\n# IntelliJ related\n*.iml\n*.ipr\n*.iws\n.idea/\n\n# The .vscode folder contains launch configuration and tasks you configure in\n# VS Code which you may wish to be included in version control, so this line\n# is commented out by default.\n#.vscode/\n\n# Flutter/Dart/Pub related\n**/doc/api/\n**/ios/Flutter/.last_build_id\n.dart_tool/\n.flutter-plugins\n.flutter-plugins-dependencies\n.pub-cache/\n.pub/\n/build/\n\n# Web related\nlib/generated_plugin_registrant.dart\n\n# Symbolication related\napp.*.symbols\n\n# Obfuscation related\napp.*.map.json\n\n# Android Studio will place build artifacts here\n/android/app/debug\n/android/app/profile\n/android/app/release\n"
  },
  {
    "path": ".metadata",
    "content": "# This file tracks properties of this Flutter project.\n# Used by Flutter tool to assess capabilities and perform upgrades etc.\n#\n# This file should be version controlled and should not be manually edited.\n\nversion:\n  revision: f7a6a7906be96d2288f5d63a5a54c515a6e987fe\n  channel: stable\n\nproject_type: app\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n    // Use IntelliSense to learn about possible attributes.\n    // Hover to view descriptions of existing attributes.\n    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Dev Android\",\n            \"request\": \"launch\",\n            \"type\": \"dart\",\n            \"args\": [\n                \"--flavor\",\n                \"dev\"\n            ],\n        },\n        {\n            \"name\": \"Dev IOS\",\n            \"request\": \"launch\",\n            \"type\": \"dart\",\n        }\n    ]\n}"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "# Otraku\nAn unofficial AniList app.\n\n<p align='center'>\n<img src='https://user-images.githubusercontent.com/35681808/115051277-4fe46680-9ee5-11eb-9cf7-ac62529c4760.png' width='200'>\n</p>\n\n<p align='center'>\n<a href='https://play.google.com/store/apps/details?id=com.otraku.app'>Google Play</a> • <a href='https://apt.izzysoft.de/fdroid/index/apk/com.otraku.app'>IzzyOnDroid (F-Droid)</a> • <a href='https://sites.google.com/view/otraku/privacy-policy'>Privacy Policy</a>\n</p>\n<p align='center'>\nThe iOS .ipa and the android .apk are bundled with each Github release.\n</p>\n\n<details><p align='center'>\n<summary>Screenshots</summary>\n\n<img width=18% src='https://github.com/lotusprey/otraku/assets/35681808/b6d04e69-e0ae-4b4d-b9bb-621b85b6f220'>\n<img width=18% src='https://github.com/lotusprey/otraku/assets/35681808/62cf5d01-43cd-4aba-a292-1bf08e7500b6'>\n<img width=18% src='https://github.com/lotusprey/otraku/assets/35681808/63e50f2e-30ca-4e36-8ed0-0d34048060b7'>\n<img width=18% src='https://github.com/lotusprey/otraku/assets/35681808/692c6bf8-a5c0-41bf-8bc4-4ce16909550a'>\n<img width=18% src='https://github.com/lotusprey/otraku/assets/35681808/a68aac0e-7f2a-4ae0-b0d5-d06d6f485f87'>\n<img width=18% src='https://github.com/lotusprey/otraku/assets/35681808/40d47bfc-a0eb-43fa-be70-21aa8ae59122'>\n<img width=18% src='https://github.com/lotusprey/otraku/assets/35681808/560d8261-a206-4403-87e3-2207bdbb1c23'>\n<img width=18% src='https://github.com/lotusprey/otraku/assets/35681808/7fcfd048-80c2-472f-a833-548ea6b7fafe'>\n<img width=18% src='https://github.com/lotusprey/otraku/assets/35681808/c8ab401e-1098-4e69-992b-1d6bc3513ddd'>\n<img width=18% src='https://github.com/lotusprey/otraku/assets/35681808/5bcd8eff-2cd7-4f35-90a3-145156a83e2a'>\n\n</p></details>\n<details><summary>Building for android</summary>\n\n1. Run `flutter build apk --split-per-abi`\n2. Grab the apk release build file with your required ABI\n</details>\n<details><summary>Building for iOS</summary>\n\n1. Run `flutter build ios --no-codesign`\n2. Copy `./build/ios/iphoneos/Runner.app` into a `Payload` directory\n3. Compress `Payload` and change extension to `.ipa`\n</details>\n"
  },
  {
    "path": "analysis_options.yaml",
    "content": "include: package:flutter_lints/flutter.yaml\n\nlinter:\n  rules:\n    # Often unnecessary.\n    use_key_in_widget_constructors: false\n\n    # For closures.\n    prefer_function_declarations_over_variables: false\n\nformatter:\n  page_width: 100\n"
  },
  {
    "path": "android/.gitignore",
    "content": "gradle-wrapper.jar\n/.gradle\n/captures/\n/gradlew\n/gradlew.bat\n/keystore.jks\n/keystore.properties\n/local.properties\nGeneratedPluginRegistrant.java\n.cxx/"
  },
  {
    "path": "android/app/build.gradle.kts",
    "content": "import java.util.Properties\nimport java.io.FileInputStream\n\nplugins {\n    id(\"com.android.application\")\n    id(\"kotlin-android\")\n    id(\"dev.flutter.flutter-gradle-plugin\")\n}\n\nval keystoreProperties = Properties()\nval keystorePropertiesFile = rootProject.file(\"keystore.properties\")\nif (keystorePropertiesFile.exists()) {\n    keystoreProperties.load(FileInputStream(keystorePropertiesFile))\n}\n\nandroid {\n    namespace = \"com.otraku.app\"\n    compileSdk = flutter.compileSdkVersion\n    ndkVersion = flutter.ndkVersion\n\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_17\n        targetCompatibility = JavaVersion.VERSION_17\n\n        // Desugaring is required by flutter_local_notifications.\n        isCoreLibraryDesugaringEnabled = true\n    }\n\n    kotlinOptions {\n        jvmTarget = JavaVersion.VERSION_17.toString()\n    }\n\n    defaultConfig {\n        applicationId = \"com.otraku.app\"\n        minSdk = flutter.minSdkVersion\n        targetSdk = flutter.targetSdkVersion\n        versionCode = flutter.versionCode\n        versionName = flutter.versionName\n    }\n\n    signingConfigs {\n        create(\"release\") {\n            storeFile = file(rootDir.canonicalPath + \"/\" + keystoreProperties[\"releaseKeyStore\"])\n            storePassword = keystoreProperties[\"releaseStorePassword\"] as String\n            keyPassword = keystoreProperties[\"releaseKeyPassword\"] as String\n            keyAlias = keystoreProperties[\"releaseKeyAlias\"] as String\n        }\n    }\n\n    buildTypes {\n        release {\n            signingConfig = signingConfigs.getByName(\"release\")\n        }\n    }\n\n    flavorDimensions += \"default\"\n    productFlavors {\n        create(\"dev\") {\n            dimension = \"default\"\n            applicationIdSuffix = \".dev\"\n        }\n    }\n}\n\ndependencies {\n    // Desugaring is required by flutter_local_notifications.\n    coreLibraryDesugaring(\"com.android.tools:desugar_jdk_libs:2.1.5\")\n}\n\nflutter {\n    source = \"../..\"\n}\n"
  },
  {
    "path": "android/app/src/debug/AndroidManifest.xml",
    "content": "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\" package=\"com.otraku.app\">\n    <!-- Flutter needs it to communicate with the running application\n         to allow setting breakpoints, to provide hot reload, etc.\n    -->\n    <uses-permission android:name=\"android.permission.INTERNET\"/>\n</manifest>\n"
  },
  {
    "path": "android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <background android:drawable=\"@color/ic_launcher_background\"/>\n  <foreground android:drawable=\"@drawable/ic_launcher_foreground\"/>\n</adaptive-icon>\n"
  },
  {
    "path": "android/app/src/dev/res/values/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n  <color name=\"ic_launcher_background\">#E3F2FF</color>\n</resources>\n"
  },
  {
    "path": "android/app/src/dev/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <string name=\"app_name\">Otraku</string>\n</resources>"
  },
  {
    "path": "android/app/src/main/AndroidManifest.xml",
    "content": "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\" package=\"com.otraku.app\">\n     <!-- Note: Set the allowBackup property to false, because it supposedly causes\n         exception \"java.security.InvalidKeyException:Failed to unwrap key\" -->\n     <!-- Note: Additionally the property fullBackupContent was configured with\n         the settings stored in \"./res/xml/backup_rules.xml\" -->\n     \n     <!-- Internet. -->\n     <uses-permission android:name=\"android.permission.INTERNET\" />\n\n     <!-- Url launcher. -->\n     <queries>\n       <intent>\n         <action android:name=\"android.intent.action.VIEW\" />\n         <data android:scheme=\"https\" />\n       </intent>\n     </queries>\n\n     <application \n          android:allowBackup=\"false\" \n          android:fullBackupContent=\"@xml/backup_rules\" \n          android:name=\"${applicationName}\"\n          android:label=\"@string/app_name\"\n          android:icon=\"@mipmap/ic_launcher\">\n          <activity \n               android:name=\".MainActivity\" \n               android:exported=\"true\"\n               android:launchMode=\"singleTop\" \n               android:theme=\"@style/LaunchTheme\" \n               android:configChanges=\"orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode\" \n               android:hardwareAccelerated=\"true\" \n               android:windowSoftInputMode=\"adjustResize\">\n               <!-- Specifies an Android theme to apply to this Activity as soon as\n                 the Android process has started. This theme is visible to the user\n                 while the Flutter UI initializes. After that, this theme continues\n                 to determine the Window background behind the Flutter UI. -->\n               <meta-data android:name=\"io.flutter.embedding.android.NormalTheme\" android:resource=\"@style/NormalTheme\" />\n               <!-- Displays an Android View that continues showing the launch screen\n                 Drawable until Flutter paints its first frame, then this splash\n                 screen fades out. A splash screen is useful to avoid any visual\n                 gap between the end of Android's launch screen and the painting of\n                 Flutter's first frame. -->\n               <intent-filter>\n                    <action android:name=\"android.intent.action.MAIN\"/>\n                    <category android:name=\"android.intent.category.LAUNCHER\"/>\n               </intent-filter>\n               <!-- Deep link for logging in. -->\n               <intent-filter>\n                    <action android:name=\"android.intent.action.VIEW\" />\n                    <category android:name=\"android.intent.category.DEFAULT\" />\n                    <category android:name=\"android.intent.category.BROWSABLE\" />\n                    <data android:scheme=\"app\" />\n                    <data android:host=\"otraku\" android:pathPrefix=\"/auth\" />\n               </intent-filter>\n               <!-- Deep links for AniList. -->\n               <intent-filter>\n                    <action android:name=\"android.intent.action.VIEW\" />\n                    <category android:name=\"android.intent.category.DEFAULT\" />\n                    <category android:name=\"android.intent.category.BROWSABLE\" />\n                    <data android:scheme=\"https\" />\n                    <data android:host=\"anilist.co\" android:pathPrefix=\"/anime/\" />\n                    <data android:host=\"anilist.co\" android:pathPrefix=\"/manga/\" />\n                    <data android:host=\"anilist.co\" android:pathPrefix=\"/character/\" />\n                    <data android:host=\"anilist.co\" android:pathPrefix=\"/staff/\" />\n                    <data android:host=\"anilist.co\" android:pathPrefix=\"/studio/\" />\n                    <data android:host=\"anilist.co\" android:pathPrefix=\"/review/\" />\n                    <data android:host=\"anilist.co\" android:pathPrefix=\"/user/\" />\n                    <data android:host=\"anilist.co\" android:pathPrefix=\"/activity/\" />\n                    <data android:host=\"anilist.co\" android:pathPrefix=\"/forum\" />\n               </intent-filter>\n          </activity>\n          <meta-data android:name=\"flutterEmbedding\" android:value=\"2\" />\n     </application>\n</manifest>\n"
  },
  {
    "path": "android/app/src/main/kotlin/com/example/otraku/MainActivity.kt",
    "content": "package com.otraku.app\n\nimport io.flutter.embedding.android.FlutterActivity\n\nclass MainActivity: FlutterActivity() {\n}"
  },
  {
    "path": "android/app/src/main/res/drawable/launch_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<layer-list xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <item>\n        <color android:color=\"@color/ic_launcher_background\" />\n    </item>\n\n    <item>\n        <bitmap\n            android:gravity=\"center\"\n            android:src=\"@drawable/ic_launcher_foreground\" />\n    </item>\n</layer-list>\n"
  },
  {
    "path": "android/app/src/main/res/drawable-v21/launch_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<layer-list xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <item>\n        <color android:color=\"@color/ic_launcher_background\" />\n    </item>\n\n    <item>\n        <bitmap\n            android:gravity=\"center\"\n            android:src=\"@drawable/ic_launcher_foreground\" />\n    </item>\n</layer-list>"
  },
  {
    "path": "android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <background android:drawable=\"@color/ic_launcher_background\" />\n  <foreground android:drawable=\"@drawable/ic_launcher_foreground\" />\n  <monochrome android:drawable=\"@mipmap/ic_launcher_monochrome\" />\n</adaptive-icon>\n"
  },
  {
    "path": "android/app/src/main/res/values/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n\t<color name=\"ic_launcher_background\">#ffffff</color>\n</resources>"
  },
  {
    "path": "android/app/src/main/res/values/strings.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <string name=\"app_name\">Otraku</string>\n</resources>"
  },
  {
    "path": "android/app/src/main/res/values/styles.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <!-- Theme applied to the Android Window while the process is starting -->\n    <style name=\"LaunchTheme\" parent=\"Theme.AppCompat.Light.NoActionBar\">\n        <!-- Show a splash screen on the activity. Automatically removed when\n             Flutter draws its first frame -->\n        <item name=\"android:windowBackground\">@drawable/launch_background</item>\n        <item name=\"android:statusBarColor\">@android:color/transparent</item>\n        <item name=\"android:windowLightStatusBar\">false</item>\n    </style>\n    <!-- Theme applied to the Android Window as soon as the process has started.\n         This theme determines the color of the Android Window while your\n         Flutter UI initializes, as well as behind your Flutter UI while its\n         running.\n         \n         This Theme is only used starting with V2 of Flutter's Android embedding. -->\n    <style name=\"NormalTheme\" parent=\"Theme.AppCompat.Light.NoActionBar\">\n        <item name=\"android:windowBackground\">@drawable/launch_background</item>\n    </style>\n</resources>\n"
  },
  {
    "path": "android/app/src/main/res/values-night/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n\t<color name=\"ic_launcher_background\">#0D161E</color>\n</resources>"
  },
  {
    "path": "android/app/src/main/res/values-night/styles.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->\n    <style name=\"LaunchTheme\" parent=\"Theme.AppCompat.Light.NoActionBar\">\n        <!-- Show a splash screen on the activity. Automatically removed when\n             Flutter draws its first frame -->\n        <item name=\"android:windowBackground\">@drawable/launch_background</item>\n        <item name=\"android:statusBarColor\">@android:color/transparent</item>\n        <item name=\"android:windowLightStatusBar\">true</item>\n    </style>\n    <!-- Theme applied to the Android Window as soon as the process has started.\n         This theme determines the color of the Android Window while your\n         Flutter UI initializes, as well as behind your Flutter UI while its\n         running.\n         \n         This Theme is only used starting with V2 of Flutter's Android embedding. -->\n    <style name=\"NormalTheme\" parent=\"Theme.AppCompat.Light.NoActionBar\">\n        <item name=\"android:windowBackground\">@drawable/launch_background</item>\n    </style>\n</resources>\n"
  },
  {
    "path": "android/app/src/main/res/xml/backup_rules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<full-backup-content>\n    <exclude domain=\"sharedpref\" path=\"FlutterSecureStorage\"/>\n</full-backup-content>"
  },
  {
    "path": "android/app/src/profile/AndroidManifest.xml",
    "content": "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\" package=\"com.otraku.app\">\n     <!-- Note: Set the allowBackup property to false, because it supposedly causes\n         exception \"java.security.InvalidKeyException:Failed to unwrap key\" -->\n     <!-- Note: Additionally the property fullBackupContent was configured with\n         the settings stored in \"./res/xml/backup_rules.xml\" -->\n     \n     <!-- Internet -->\n     <uses-permission android:name=\"android.permission.INTERNET\" />\n\n     <!-- Url launcher -->\n     <queries>\n       <intent>\n         <action android:name=\"android.intent.action.VIEW\" />\n         <data android:scheme=\"https\" />\n       </intent>\n     </queries>\n\n     <application \n          android:allowBackup=\"false\" \n          android:fullBackupContent=\"@xml/backup_rules\" \n          android:name=\"${applicationName}\"\n          android:label=\"@string/app_name\" \n          android:icon=\"@mipmap/ic_launcher\">\n          <activity \n               android:name=\".MainActivity\" \n               android:exported=\"true\"\n               android:launchMode=\"singleTop\" \n               android:theme=\"@style/LaunchTheme\" \n               android:configChanges=\"orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode\" \n               android:hardwareAccelerated=\"true\" \n               android:windowSoftInputMode=\"adjustResize\">\n               <!-- Specifies an Android theme to apply to this Activity as soon as\n                 the Android process has started. This theme is visible to the user\n                 while the Flutter UI initializes. After that, this theme continues\n                 to determine the Window background behind the Flutter UI. -->\n               <meta-data android:name=\"io.flutter.embedding.android.NormalTheme\" android:resource=\"@style/NormalTheme\" />\n               <!-- Displays an Android View that continues showing the launch screen\n                 Drawable until Flutter paints its first frame, then this splash\n                 screen fades out. A splash screen is useful to avoid any visual\n                 gap between the end of Android's launch screen and the painting of\n                 Flutter's first frame. -->\n               <intent-filter>\n                    <action android:name=\"android.intent.action.MAIN\"/>\n                    <category android:name=\"android.intent.category.LAUNCHER\"/>\n               </intent-filter>\n          </activity>\n          <meta-data android:name=\"flutterEmbedding\" android:value=\"2\" />\n     </application>\n</manifest>\n"
  },
  {
    "path": "android/build.gradle.kts",
    "content": "allprojects {\n    repositories {\n        google()\n        mavenCentral()\n    }\n}\n\nval newBuildDir: Directory =\n    rootProject.layout.buildDirectory\n        .dir(\"../../build\")\n        .get()\nrootProject.layout.buildDirectory.value(newBuildDir)\n\nsubprojects {\n    val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)\n    project.layout.buildDirectory.value(newSubprojectBuildDir)\n}\nsubprojects {\n    project.evaluationDependsOn(\":app\")\n}\n\ntasks.register<Delete>(\"clean\") {\n    delete(rootProject.layout.buildDirectory)\n}\n"
  },
  {
    "path": "android/gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.14.3-all.zip\n"
  },
  {
    "path": "android/gradle.properties",
    "content": "org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError\nandroid.useAndroidX=true\nandroid.enableJetifier=true"
  },
  {
    "path": "android/settings.gradle.kts",
    "content": "pluginManagement {\n    val flutterSdkPath =\n        run {\n            val properties = java.util.Properties()\n            file(\"local.properties\").inputStream().use { properties.load(it) }\n            val flutterSdkPath = properties.getProperty(\"flutter.sdk\")\n            require(flutterSdkPath != null) { \"flutter.sdk not set in local.properties\" }\n            flutterSdkPath\n        }\n\n    includeBuild(\"$flutterSdkPath/packages/flutter_tools/gradle\")\n\n    repositories {\n        google()\n        mavenCentral()\n        gradlePluginPortal()\n    }\n}\n\nplugins {\n    id(\"dev.flutter.flutter-plugin-loader\") version \"1.0.0\"\n    id(\"com.android.application\") version \"8.11.1\" apply false\n    id(\"org.jetbrains.kotlin.android\") version \"2.2.20\" apply false\n}\n\ninclude(\":app\")\n\n"
  },
  {
    "path": "fastlane/metadata/android/de/full_description.txt",
    "content": "<i>Otraku</i> möchte ein voll funktionsfähiger und anpassbarer Client für AniList sein, ohne Werbung. Die App ermöglicht das Betrachten und Bearbeiten Deiner Anime/Manga Listen, das Browses und Filtern von Medien, Interaktionen mit anderen Nutzern, und mehr!\n\n<b>Aktuelle Funktionen:</b>\n\n* Zeigen Sie Ihre Anime- und Manga-Listen an und bearbeiten Sie sie\n* Erkunde Anime, Manga, Charaktere, Mitarbeiter, Studios, Benutzer und Rezensionen\n* Folgende / globale Feeds anzeigen\n* Gefällt mir Aktivitäten und Kommentare (Kommentieren wird noch nicht unterstützt)\n* Wählen Sie verschiedene App-Themen\n* Konfigurieren Sie einige AniList-Einstellungen\n"
  },
  {
    "path": "fastlane/metadata/android/de/short_description.txt",
    "content": "Inoffizieller AniList-Client für Anime- und Manga-Tracking"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/59.txt",
    "content": "- Added calendar in discover to view and filter new episode schedules\n- Option for pure background in settings now not only makes dark backgrounds black, but also light backgrounds white\n- Fixed lazy-loading in \"Following\" on the media page\n- Other fixes and improvements"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/63.txt",
    "content": "- Collection searching goes through both titles and notes\n- Activity replies have a \"Reply\" button for automatic mentions\n- Tapping on markdown images opens them as a popup\n- Tapping on user mentions is not handled as a link, but directly opens the user page\n- Tapping on a ranking in a media's statistics page redirects to the discover tab with added filters\n- Deep linking on android, if configured in settings\n- List status on related media in media pages\n- And other visual improvements and fixes"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/65.txt",
    "content": "- Added collection filters for public/private entries and for entries with/without notes\n- Changed release year filter design\n- In fields, you can long-tap the decrement/increment buttons to set the value to min/max\n- Reduced minimum year in release year filter to 1917\n- AniList settings are saved with a floating action button now\n- Fixed collection refresh forgetting the selected list\n- Fixed missing entries in collections and ignored name preferences\n- Fixed settings not reflecting account switching"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/66.txt",
    "content": "- Added collection filters for public/private entries and for entries with/without notes\n- Changed release year filter design\n- In fields, you can long-tap the decrement/increment buttons to set the value to min/max\n- Reduced minimum year in release year filter to 1917\n- AniList settings are saved with a floating action button now\n- Fixed collection refresh forgetting the selected list\n- Fixed missing entries in collections, ignored name preferences and settings not reflecting account switching"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/69.txt",
    "content": "- AniList Markdown is supported almost fully\n- AniList links in markdown text are opened within the app\n- More markdown quick access buttons in the composition sheet\n- Collection previews can be filtered like full collections\n- User/Discover reviews can be filtered by media type\n- You can long-press to copy a media description\n- Redesigned media overview tab and other elements\n- Fixed bugs around deep link opening\n- Image popups are also cached\n- Other fixes and improvements"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/72.txt",
    "content": "- If your filtered collections are empty, a button can redirect you to discover with copied filters\n- Tag categories in the tag sheet are sorted alphabetically\n- Separate synonym titles on media pages\n- Reordered fields in the entry sheet and chapter/volume fields switch based on left-handed mode\n- Added an indication on whether collection/discover filters are active\n- Refreshable media/user pages\n- Fixed emojis, some filter names, collection tiles\n- Visual tweaks and slightly darker dark mode"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/73.txt",
    "content": "- Toggled activity/reply like buttons use the primary color\n- Cleaner error messages for failed connection/requests that now appear as toasts\n- Replaced \"gradient\" sheets for activity menus, discover type selection and the like with normal sheets (may still need polishing)\n- Fixed collection sorting\n- Fixed activity/reply like timeout message\n- Fixed home tab switching\n- Fixed user refresh retrying multiple times"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/77.txt",
    "content": "- Tablet support with better layout on wide screens\n- New studio page design\n- New recommendations design in the media page\n- Activity/Reply like icons are different depending on whether the item is liked or not\n- Toast messages were replaced by snackbars\n- Overall design has been tweaked in many areas\n- Fixed progress-incrementing button spamming\n- Fixed language order when selecting voice actor language\n- And more tweaks and fixes"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/80.txt",
    "content": "- In the filter sheets for collections and discover, you can set a custom default configuration\n- Basic AniList interactions are now supported without logging in\n- Easier account switching from the profile tab\n- You can reorder favorites and easily unfavorite them\n- Timestamps are now relative, but you can tap them for an absolute date\n- When incrementing the episode count from 0 on an entry in some lists, a pop up will offer to also change the list status"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/82.txt",
    "content": "- Chips on the media page are now a grid, not a scrollable row\n- Fixed the the favorites editing button appearing in others' favorites\n- Fixed edge cases in entry saving/removing\n- Fixed list statuses in media recommendations mixing up anime and manga\n- Fixed notification timestamps taking too much space\n- Shortened the snackbar timeout"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/83.txt",
    "content": "- In the collection filter sheets for both your anime and manga collection, you can explicitly set the preview collection sorting, separately from the one for the full collection. The exclusive airing sorting for anime collection preview toggle is removed from settings.\n- Added a doujin filter in the discover filter sheet.\n- While on the profile tab of the home screen, tapping the profile icon will scroll to top like before. But now it will also open settings, if you're already at the top."
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/84.txt",
    "content": "- Added forum page with thread filters\n- Added thread pages with navigation, commenting, liking and subscribing (thread writing/editing is not yet done)\n- Added a tab on media pages with related threads\n- Added tabs with user's threads and comments on users' social pages\n- Fixed bugs related to collections, advanced scores and home page search focusing"
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/86.txt",
    "content": "- Added recommendations to discover.\n- Improved the recommendations tab design in the media page.\n- Replaced left-handed mode setting with a more general \"button orientation\" setting.\n- Fixed some alignment issues in thread views.\n- Fixed search bar freezing when fast-switching tabs.\n\nImportant: some people have been facing performance issues after the last update. I upgraded the app engine and I'm hoping that will resolve the regressions these people face, but I can't guarantee it."
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/87.txt",
    "content": "- Fix home page tab scrolling.\n- Use new material page transition on Android."
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/89.txt",
    "content": "- Added activities to a tab in the media page, with the ability to filter them by people you follow.\n- Added custom list management (reordering not supported yet).\n- Improved advanced score section management.\n- Activities, replies, notifications and reviews are outlined when the high contrast settings is enabled (more of this in the future).\n- Image caching is disabled for markdown text (it bloats the cache)."
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/92.txt",
    "content": "- Expanded collections now show and filter all lists at once, though you can still view individual lists.\n- High contrast mode now affects all tiles in the UI.\n- Text scaling is now unconstrained and tiles adjust size to accommodate the text."
  },
  {
    "path": "fastlane/metadata/android/en-US/changelogs/94.txt",
    "content": "- Added: support for the new AniList notification types - this fixes the media and other parts of the app not loading.\n- Added: \"Self\" option in the media activity filter.\n- Improved: Bar charts in user/media statistics are not horizontal, more compact and more informative.\n- Improved: Buttons on the user page are now a grid.\n- Fixed: wrong status bar tint on older android versions.\n- Fixed: layout issues with the text being cut off."
  },
  {
    "path": "fastlane/metadata/android/en-US/full_description.txt",
    "content": "Otraku aims to support most AniList features and it already covers:\n\n- Tracking media and managing/filtering collections\n- Browsing/Filtering media/characters/staff/studios/users/reviews/recommendations\n- Forum\n- General/User activity feeds\n- Composing messages\n- Calendar for release schedules\n- Customization with different themes and other options\n\nAnd more!"
  },
  {
    "path": "fastlane/metadata/android/en-US/short_description.txt",
    "content": "An unofficial AniList client for Android and iOS"
  },
  {
    "path": "fastlane/metadata/android/en-US/title.txt",
    "content": "Otraku"
  },
  {
    "path": "flutter_launcher_icons-dev.yaml",
    "content": "flutter_icons:\n  ios: true\n  android: true\n  image_path: \"assets/icons/ios.png\"\n  adaptive_icon_background: \"#E3F2FF\"\n  adaptive_icon_foreground: \"assets/icons/android.png\""
  },
  {
    "path": "ios/.gitignore",
    "content": "**/dgph\n*.mode1v3\n*.mode2v3\n*.moved-aside\n*.pbxuser\n*.perspectivev3\n**/*sync/\n.sconsign.dblite\n.tags*\n**/.vagrant/\n**/DerivedData/\nIcon?\n**/Pods/\n**/.symlinks/\nprofile\nxcuserdata\n**/.generated/\nFlutter/App.framework\nFlutter/Flutter.framework\nFlutter/Flutter.podspec\nFlutter/Generated.xcconfig\nFlutter/ephemeral/\nFlutter/app.flx\nFlutter/app.zip\nFlutter/flutter_assets/\nFlutter/flutter_export_environment.sh\nServiceDefinitions.json\nRunner/GeneratedPluginRegistrant.*\n\n# Exceptions to above rules.\n!default.mode1v3\n!default.mode2v3\n!default.pbxuser\n!default.perspectivev3\n"
  },
  {
    "path": "ios/Flutter/AppFrameworkInfo.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>CFBundleDevelopmentRegion</key>\n  <string>$(DEVELOPMENT_LANGUAGE)</string>\n  <key>CFBundleExecutable</key>\n  <string>App</string>\n  <key>CFBundleIdentifier</key>\n  <string>io.flutter.flutter.app</string>\n  <key>CFBundleInfoDictionaryVersion</key>\n  <string>6.0</string>\n  <key>CFBundleName</key>\n  <string>App</string>\n  <key>CFBundlePackageType</key>\n  <string>FMWK</string>\n  <key>CFBundleShortVersionString</key>\n  <string>1.0</string>\n  <key>CFBundleSignature</key>\n  <string>????</string>\n  <key>CFBundleVersion</key>\n  <string>1.0</string>\n  <key>MinimumOSVersion</key>\n  <string>13.0</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "ios/Flutter/Debug.xcconfig",
    "content": "#include \"Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig\"\n#include \"Generated.xcconfig\"\n"
  },
  {
    "path": "ios/Flutter/Profile.xcconfig",
    "content": "#include \"Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig\"\n#include \"Generated.xcconfig\"\n"
  },
  {
    "path": "ios/Flutter/Release.xcconfig",
    "content": "#include \"Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig\"\n#include \"Generated.xcconfig\"\n"
  },
  {
    "path": "ios/Podfile",
    "content": "# Uncomment this line to define a global platform for your project\nplatform :ios, '18.0'\n\n# CocoaPods analytics sends network stats synchronously affecting flutter build latency.\nENV['COCOAPODS_DISABLE_STATS'] = 'true'\n\nproject 'Runner', {\n  'Debug' => :debug,\n  'Profile' => :release,\n  'Release' => :release,\n}\n\ndef flutter_root\n  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)\n  unless File.exist?(generated_xcode_build_settings_path)\n    raise \"#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first\"\n  end\n\n  File.foreach(generated_xcode_build_settings_path) do |line|\n    matches = line.match(/FLUTTER_ROOT\\=(.*)/)\n    return matches[1].strip if matches\n  end\n  raise \"FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get\"\nend\n\nrequire File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)\n\nflutter_ios_podfile_setup\n\ntarget 'Runner' do\n  use_frameworks!\n  use_modular_headers!\n\n  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))\nend\n\npost_install do |installer|\n  installer.pods_project.targets.each do |target|\n    flutter_additional_ios_build_settings(target)\n  end\nend\n"
  },
  {
    "path": "ios/Runner/AppDelegate.swift",
    "content": "import UIKit\nimport Flutter\n\n@main\n@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {\n  override func application(\n    _ application: UIApplication,\n    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?\n  ) -> Bool {\n    return super.application(application, didFinishLaunchingWithOptions: launchOptions)\n  }\n\n  func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {\n    GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)\n  }\n}\n"
  },
  {
    "path": "ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json",
    "content": "{\"images\":[{\"size\":\"20x20\",\"idiom\":\"iphone\",\"filename\":\"AppIcon-dev-20x20@2x.png\",\"scale\":\"2x\"},{\"size\":\"20x20\",\"idiom\":\"iphone\",\"filename\":\"AppIcon-dev-20x20@3x.png\",\"scale\":\"3x\"},{\"size\":\"29x29\",\"idiom\":\"iphone\",\"filename\":\"AppIcon-dev-29x29@1x.png\",\"scale\":\"1x\"},{\"size\":\"29x29\",\"idiom\":\"iphone\",\"filename\":\"AppIcon-dev-29x29@2x.png\",\"scale\":\"2x\"},{\"size\":\"29x29\",\"idiom\":\"iphone\",\"filename\":\"AppIcon-dev-29x29@3x.png\",\"scale\":\"3x\"},{\"size\":\"40x40\",\"idiom\":\"iphone\",\"filename\":\"AppIcon-dev-40x40@2x.png\",\"scale\":\"2x\"},{\"size\":\"40x40\",\"idiom\":\"iphone\",\"filename\":\"AppIcon-dev-40x40@3x.png\",\"scale\":\"3x\"},{\"size\":\"60x60\",\"idiom\":\"iphone\",\"filename\":\"AppIcon-dev-60x60@2x.png\",\"scale\":\"2x\"},{\"size\":\"60x60\",\"idiom\":\"iphone\",\"filename\":\"AppIcon-dev-60x60@3x.png\",\"scale\":\"3x\"},{\"size\":\"20x20\",\"idiom\":\"ipad\",\"filename\":\"AppIcon-dev-20x20@1x.png\",\"scale\":\"1x\"},{\"size\":\"20x20\",\"idiom\":\"ipad\",\"filename\":\"AppIcon-dev-20x20@2x.png\",\"scale\":\"2x\"},{\"size\":\"29x29\",\"idiom\":\"ipad\",\"filename\":\"AppIcon-dev-29x29@1x.png\",\"scale\":\"1x\"},{\"size\":\"29x29\",\"idiom\":\"ipad\",\"filename\":\"AppIcon-dev-29x29@2x.png\",\"scale\":\"2x\"},{\"size\":\"40x40\",\"idiom\":\"ipad\",\"filename\":\"AppIcon-dev-40x40@1x.png\",\"scale\":\"1x\"},{\"size\":\"40x40\",\"idiom\":\"ipad\",\"filename\":\"AppIcon-dev-40x40@2x.png\",\"scale\":\"2x\"},{\"size\":\"76x76\",\"idiom\":\"ipad\",\"filename\":\"AppIcon-dev-76x76@1x.png\",\"scale\":\"1x\"},{\"size\":\"76x76\",\"idiom\":\"ipad\",\"filename\":\"AppIcon-dev-76x76@2x.png\",\"scale\":\"2x\"},{\"size\":\"83.5x83.5\",\"idiom\":\"ipad\",\"filename\":\"AppIcon-dev-83.5x83.5@2x.png\",\"scale\":\"2x\"},{\"size\":\"1024x1024\",\"idiom\":\"ios-marketing\",\"filename\":\"AppIcon-dev-1024x1024@1x.png\",\"scale\":\"1x\"}],\"info\":{\"version\":1,\"author\":\"xcode\"}}"
  },
  {
    "path": "ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"size\" : \"20x20\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-20x20@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"20x20\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-20x20@3x.png\",\n      \"scale\" : \"3x\"\n    },\n    {\n      \"size\" : \"29x29\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-29x29@1x.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"size\" : \"29x29\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-29x29@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"29x29\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-29x29@3x.png\",\n      \"scale\" : \"3x\"\n    },\n    {\n      \"size\" : \"40x40\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-40x40@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"40x40\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-40x40@3x.png\",\n      \"scale\" : \"3x\"\n    },\n    {\n      \"size\" : \"60x60\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-60x60@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"60x60\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-60x60@3x.png\",\n      \"scale\" : \"3x\"\n    },\n    {\n      \"size\" : \"20x20\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-20x20@1x.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"size\" : \"20x20\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-20x20@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"29x29\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-29x29@1x.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"size\" : \"29x29\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-29x29@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"40x40\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-40x40@1x.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"size\" : \"40x40\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-40x40@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"76x76\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-76x76@1x.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"size\" : \"76x76\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-76x76@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"83.5x83.5\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-83.5x83.5@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"1024x1024\",\n      \"idiom\" : \"ios-marketing\",\n      \"filename\" : \"Icon-App-1024x1024@1x.png\",\n      \"scale\" : \"1x\"\n    }\n  ],\n  \"info\" : {\n    \"version\" : 1,\n    \"author\" : \"xcode\"\n  }\n}\n"
  },
  {
    "path": "ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"splash_icon-2.png\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"filename\" : \"splash_icon-1.png\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"filename\" : \"splash_icon.png\",\n      \"idiom\" : \"universal\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md",
    "content": "# Launch Screen Assets\n\nYou can customize the launch screen with your own desired assets by replacing the image files in this directory.\n\nYou can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images."
  },
  {
    "path": "ios/Runner/Base.lproj/LaunchScreen.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"18122\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" launchScreen=\"YES\" colorMatched=\"YES\" initialViewController=\"01J-lp-oVM\">\n    <device id=\"retina6_1\" orientation=\"portrait\" appearance=\"light\"/>\n    <dependencies>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"18093\"/>\n        <capability name=\"documents saved in the Xcode 8 format\" minToolsVersion=\"8.0\"/>\n    </dependencies>\n    <scenes>\n        <!--View Controller-->\n        <scene sceneID=\"EHf-IW-A2E\">\n            <objects>\n                <viewController id=\"01J-lp-oVM\" sceneMemberID=\"viewController\">\n                    <layoutGuides>\n                        <viewControllerLayoutGuide type=\"top\" id=\"Ydg-fD-yQy\"/>\n                        <viewControllerLayoutGuide type=\"bottom\" id=\"xbc-2k-c8Z\"/>\n                    </layoutGuides>\n                    <view key=\"view\" contentMode=\"scaleToFill\" id=\"Ze5-6b-2t3\">\n                        <rect key=\"frame\" x=\"0.0\" y=\"0.0\" width=\"414\" height=\"896\"/>\n                        <autoresizingMask key=\"autoresizingMask\" widthSizable=\"YES\" heightSizable=\"YES\"/>\n                        <subviews>\n                            <imageView opaque=\"NO\" clipsSubviews=\"YES\" multipleTouchEnabled=\"YES\" contentMode=\"center\" image=\"LaunchImage\" translatesAutoresizingMaskIntoConstraints=\"NO\" id=\"YRO-k0-Ey4\">\n                                <rect key=\"frame\" x=\"165.5\" y=\"406.5\" width=\"83.5\" height=\"83.5\"/>\n                            </imageView>\n                        </subviews>\n                        <color key=\"backgroundColor\" red=\"0.058823529411764705\" green=\"0.090196078431372548\" blue=\"0.12156862745098039\" alpha=\"1\" colorSpace=\"custom\" customColorSpace=\"displayP3\"/>\n                        <constraints>\n                            <constraint firstItem=\"YRO-k0-Ey4\" firstAttribute=\"centerX\" secondItem=\"Ze5-6b-2t3\" secondAttribute=\"centerX\" id=\"1a2-6s-vTC\"/>\n                            <constraint firstItem=\"YRO-k0-Ey4\" firstAttribute=\"centerY\" secondItem=\"Ze5-6b-2t3\" secondAttribute=\"centerY\" id=\"4X2-HB-R7a\"/>\n                        </constraints>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"iYj-Kq-Ea1\" userLabel=\"First Responder\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n            <point key=\"canvasLocation\" x=\"76.811594202898561\" y=\"251.11607142857142\"/>\n        </scene>\n    </scenes>\n    <resources>\n        <image name=\"LaunchImage\" width=\"83.5\" height=\"83.5\"/>\n    </resources>\n</document>\n"
  },
  {
    "path": "ios/Runner/Base.lproj/Main.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"10117\" systemVersion=\"15F34\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" useTraitCollections=\"YES\" initialViewController=\"BYZ-38-t0r\">\n    <dependencies>\n        <deployment identifier=\"iOS\"/>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"10085\"/>\n    </dependencies>\n    <scenes>\n        <!--Flutter View Controller-->\n        <scene sceneID=\"tne-QT-ifu\">\n            <objects>\n                <viewController id=\"BYZ-38-t0r\" customClass=\"FlutterViewController\" sceneMemberID=\"viewController\">\n                    <layoutGuides>\n                        <viewControllerLayoutGuide type=\"top\" id=\"y3c-jy-aDJ\"/>\n                        <viewControllerLayoutGuide type=\"bottom\" id=\"wfy-db-euE\"/>\n                    </layoutGuides>\n                    <view key=\"view\" contentMode=\"scaleToFill\" id=\"8bC-Xf-vdC\">\n                        <rect key=\"frame\" x=\"0.0\" y=\"0.0\" width=\"600\" height=\"600\"/>\n                        <autoresizingMask key=\"autoresizingMask\" widthSizable=\"YES\" heightSizable=\"YES\"/>\n                        <color key=\"backgroundColor\" white=\"1\" alpha=\"1\" colorSpace=\"custom\" customColorSpace=\"calibratedWhite\"/>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"dkx-z0-nzr\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n        </scene>\n    </scenes>\n</document>\n"
  },
  {
    "path": "ios/Runner/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleDevelopmentRegion</key>\n\t<string>$(DEVELOPMENT_LANGUAGE)</string>\n\t<key>CFBundleDisplayName</key>\n\t<string>Otraku</string>\n\t<key>CFBundleExecutable</key>\n\t<string>$(EXECUTABLE_NAME)</string>\n\t<key>CFBundleIdentifier</key>\n\t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\t<key>CFBundleName</key>\n\t<string>otraku</string>\n\t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\t<key>CFBundleShortVersionString</key>\n\t<string>$(FLUTTER_BUILD_NAME)</string>\n\t<key>CFBundleSignature</key>\n\t<string>????</string>\n\t<key>LSApplicationQueriesSchemes</key>\n\t<array>\n\t\t<string>https</string>\n\t</array>\n\t<key>CFBundleURLTypes</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>CFBundleTypeRole</key>\n\t\t\t<string>Editor</string>\n\t\t\t<key>CFBundleURLName</key>\n\t\t\t<string>otraku</string>\n\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t<array>\n\t\t\t\t<string>app</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n\t<key>CFBundleVersion</key>\n\t<string>$(FLUTTER_BUILD_NUMBER)</string>\n\t<key>LSRequiresIPhoneOS</key>\n\t<true/>\n\t<key>UIBackgroundModes</key>\n\t<array>\n\t\t<string>fetch</string>\n\t\t<string>processing</string>\n\t</array>\n\t<key>UILaunchStoryboardName</key>\n\t<string>LaunchScreen</string>\n\t<key>UIMainStoryboardFile</key>\n\t<string>Main</string>\n\t<key>UISupportedInterfaceOrientations</key>\n\t<array>\n\t\t<string>UIInterfaceOrientationPortrait</string>\n\t\t<string>UIInterfaceOrientationLandscapeLeft</string>\n\t\t<string>UIInterfaceOrientationLandscapeRight</string>\n\t</array>\n\t<key>UISupportedInterfaceOrientations~ipad</key>\n\t<array>\n\t\t<string>UIInterfaceOrientationPortrait</string>\n\t\t<string>UIInterfaceOrientationPortraitUpsideDown</string>\n\t\t<string>UIInterfaceOrientationLandscapeLeft</string>\n\t\t<string>UIInterfaceOrientationLandscapeRight</string>\n\t</array>\n\t<key>UIViewControllerBasedStatusBarAppearance</key>\n\t<false/>\n\t<key>CADisableMinimumFrameDurationOnPhone</key>\n\t<true/>\n\t<key>UIApplicationSupportsIndirectInputEvents</key>\n\t<true/>\n\t<key>UIApplicationSceneManifest</key>\n\t<dict>\n\t  <key>UIApplicationSupportsMultipleScenes</key>\n\t  <false/>\n\t  <key>UISceneConfigurations</key>\n\t  <dict>\n\t    <key>UIWindowSceneSessionRoleApplication</key>\n\t    <array>\n\t      <dict>\n\t        <key>UISceneClassName</key>\n\t        <string>UIWindowScene</string>\n\t        <key>UISceneDelegateClassName</key>\n\t        <string>FlutterSceneDelegate</string>\n\t        <key>UISceneConfigurationName</key>\n\t        <string>flutter</string>\n\t        <key>UISceneStoryboardFile</key>\n\t        <string>Main</string>\n\t      </dict>\n\t    </array>\n\t  </dict>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "ios/Runner/Runner-Bridging-Header.h",
    "content": "#import \"GeneratedPluginRegistrant.h\"\n"
  },
  {
    "path": "ios/Runner.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 54;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\t1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };\n\t\t3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };\n\t\t74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };\n\t\t86DA8A2D06E6B47DD9E398A8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D7B8A4D25C8F2FF6456A7A6F /* Pods_Runner.framework */; };\n\t\t97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };\n\t\t97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };\n\t\t97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };\n/* End PBXBuildFile section */\n\n/* Begin PBXCopyFilesBuildPhase section */\n\t\t9705A1C41CF9048500538489 /* Embed Frameworks */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 10;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tname = \"Embed Frameworks\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXCopyFilesBuildPhase section */\n\n/* Begin PBXFileReference section */\n\t\t1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = \"<group>\"; };\n\t\t1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = \"<group>\"; };\n\t\t2296DE72BA8BDC1D4CA61399 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-Runner.release.xcconfig\"; path = \"Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig\"; sourceTree = \"<group>\"; };\n\t\t3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = \"<group>\"; };\n\t\t644309C1A146EDFAE2F149BD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-Runner.profile.xcconfig\"; path = \"Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig\"; sourceTree = \"<group>\"; };\n\t\t74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"Runner-Bridging-Header.h\"; sourceTree = \"<group>\"; };\n\t\t74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = \"<group>\"; };\n\t\t7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = \"<group>\"; };\n\t\t9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = \"<group>\"; };\n\t\t9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = \"<group>\"; };\n\t\t97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = \"<group>\"; };\n\t\t97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; };\n\t\t97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = \"<group>\"; };\n\t\t97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\tC5A159429C34B5D065301B18 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = \"Pods-Runner.debug.xcconfig\"; path = \"Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig\"; sourceTree = \"<group>\"; };\n\t\tD7B8A4D25C8F2FF6456A7A6F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };\n/* End PBXFileReference section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\t97C146EB1CF9000F007C117D /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t86DA8A2D06E6B47DD9E398A8 /* Pods_Runner.framework in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\t39FFA31997E18B17B73C8E11 /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tD7B8A4D25C8F2FF6456A7A6F /* Pods_Runner.framework */,\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t7B3B2B22BC5AB865ADE1F85C /* Pods */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tC5A159429C34B5D065301B18 /* Pods-Runner.debug.xcconfig */,\n\t\t\t\t2296DE72BA8BDC1D4CA61399 /* Pods-Runner.release.xcconfig */,\n\t\t\t\t644309C1A146EDFAE2F149BD /* Pods-Runner.profile.xcconfig */,\n\t\t\t);\n\t\t\tpath = Pods;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t9740EEB11CF90186004384FC /* Flutter */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,\n\t\t\t\t9740EEB21CF90195004384FC /* Debug.xcconfig */,\n\t\t\t\t7AFA3C8E1D35360C0083082E /* Release.xcconfig */,\n\t\t\t\t9740EEB31CF90195004384FC /* Generated.xcconfig */,\n\t\t\t);\n\t\t\tname = Flutter;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146E51CF9000F007C117D = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t9740EEB11CF90186004384FC /* Flutter */,\n\t\t\t\t97C146F01CF9000F007C117D /* Runner */,\n\t\t\t\t97C146EF1CF9000F007C117D /* Products */,\n\t\t\t\t7B3B2B22BC5AB865ADE1F85C /* Pods */,\n\t\t\t\t39FFA31997E18B17B73C8E11 /* Frameworks */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146EF1CF9000F007C117D /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t97C146EE1CF9000F007C117D /* Runner.app */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146F01CF9000F007C117D /* Runner */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t97C146FA1CF9000F007C117D /* Main.storyboard */,\n\t\t\t\t97C146FD1CF9000F007C117D /* Assets.xcassets */,\n\t\t\t\t97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,\n\t\t\t\t97C147021CF9000F007C117D /* Info.plist */,\n\t\t\t\t97C146F11CF9000F007C117D /* Supporting Files */,\n\t\t\t\t1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,\n\t\t\t\t1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,\n\t\t\t\t74858FAE1ED2DC5600515810 /* AppDelegate.swift */,\n\t\t\t\t74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,\n\t\t\t);\n\t\t\tpath = Runner;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146F11CF9000F007C117D /* Supporting Files */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t);\n\t\t\tname = \"Supporting Files\";\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\t97C146ED1CF9000F007C117D /* Runner */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget \"Runner\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t8B4083BA08B74BDD978A39C4 /* [CP] Check Pods Manifest.lock */,\n\t\t\t\t9740EEB61CF901F6004384FC /* Run Script */,\n\t\t\t\t97C146EA1CF9000F007C117D /* Sources */,\n\t\t\t\t97C146EB1CF9000F007C117D /* Frameworks */,\n\t\t\t\t97C146EC1CF9000F007C117D /* Resources */,\n\t\t\t\t9705A1C41CF9048500538489 /* Embed Frameworks */,\n\t\t\t\t3B06AD1E1E4923F5004D2608 /* Thin Binary */,\n\t\t\t\t88DF2E0B0C95DD5F1FAC1F3F /* [CP] Embed Pods Frameworks */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = Runner;\n\t\t\tproductName = Runner;\n\t\t\tproductReference = 97C146EE1CF9000F007C117D /* Runner.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\t97C146E61CF9000F007C117D /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tLastUpgradeCheck = 1510;\n\t\t\t\tORGANIZATIONNAME = \"\";\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\t97C146ED1CF9000F007C117D = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 7.3.1;\n\t\t\t\t\t\tLastSwiftMigration = 1100;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject \"Runner\" */;\n\t\t\tcompatibilityVersion = \"Xcode 13.0\";\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = 97C146E51CF9000F007C117D;\n\t\t\tproductRefGroup = 97C146EF1CF9000F007C117D /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\t97C146ED1CF9000F007C117D /* Runner */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXResourcesBuildPhase section */\n\t\t97C146EC1CF9000F007C117D /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,\n\t\t\t\t3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,\n\t\t\t\t97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,\n\t\t\t\t97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXResourcesBuildPhase section */\n\n/* Begin PBXShellScriptBuildPhase section */\n\t\t3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\",\n\t\t\t);\n\t\t\tname = \"Thin Binary\";\n\t\t\toutputPaths = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"/bin/sh \\\"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\\\" embed_and_thin\";\n\t\t};\n\t\t88DF2E0B0C95DD5F1FAC1F3F /* [CP] Embed Pods Frameworks */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t\t\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist\",\n\t\t\t);\n\t\t\tname = \"[CP] Embed Pods Frameworks\";\n\t\t\toutputFileListPaths = (\n\t\t\t\t\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"\\\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\\\"\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n\t\t8B4083BA08B74BDD978A39C4 /* [CP] Check Pods Manifest.lock */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\",\n\t\t\t\t\"${PODS_ROOT}/Manifest.lock\",\n\t\t\t);\n\t\t\tname = \"[CP] Check Pods Manifest.lock\";\n\t\t\toutputFileListPaths = (\n\t\t\t);\n\t\t\toutputPaths = (\n\t\t\t\t\"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"diff \\\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\\\" \\\"${PODS_ROOT}/Manifest.lock\\\" > /dev/null\\nif [ $? != 0 ] ; then\\n    # print error to STDERR\\n    echo \\\"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\\\" >&2\\n    exit 1\\nfi\\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\\necho \\\"SUCCESS\\\" > \\\"${SCRIPT_OUTPUT_FILE_0}\\\"\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n\t\t9740EEB61CF901F6004384FC /* Run Script */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t);\n\t\t\tname = \"Run Script\";\n\t\t\toutputPaths = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"/bin/sh \\\"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\\\" build\";\n\t\t};\n/* End PBXShellScriptBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\t97C146EA1CF9000F007C117D /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,\n\t\t\t\t1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin PBXVariantGroup section */\n\t\t97C146FA1CF9000F007C117D /* Main.storyboard */ = {\n\t\t\tisa = PBXVariantGroup;\n\t\t\tchildren = (\n\t\t\t\t97C146FB1CF9000F007C117D /* Base */,\n\t\t\t);\n\t\t\tname = Main.storyboard;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {\n\t\t\tisa = PBXVariantGroup;\n\t\t\tchildren = (\n\t\t\t\t97C147001CF9000F007C117D /* Base */,\n\t\t\t);\n\t\t\tname = LaunchScreen.storyboard;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXVariantGroup section */\n\n/* Begin XCBuildConfiguration section */\n\t\t249021D3217E4FDB00AE95B9 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++0x\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 16.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = iphoneos;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tVALIDATE_PRODUCT = YES;\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t249021D4217E4FDB00AE95B9 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tDEVELOPMENT_TEAM = ZBL446JY27;\n\t\t\t\tENABLE_BITCODE = NO;\n\t\t\t\tFRAMEWORK_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Flutter\",\n\t\t\t\t);\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 26.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Flutter\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.otraku.app;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Runner/Runner-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t97C147031CF9000F007C117D /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++0x\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 16.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t97C147041CF9000F007C117D /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++0x\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 16.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = iphoneos;\n\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-O\";\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tVALIDATE_PRODUCT = YES;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t97C147061CF9000F007C117D /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tDEVELOPMENT_TEAM = ZBL446JY27;\n\t\t\t\tENABLE_BITCODE = NO;\n\t\t\t\tFRAMEWORK_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Flutter\",\n\t\t\t\t);\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 26.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Flutter\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.otraku.app;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Runner/Runner-Bridging-Header.h\";\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t97C147071CF9000F007C117D /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tDEVELOPMENT_TEAM = ZBL446JY27;\n\t\t\t\tENABLE_BITCODE = NO;\n\t\t\t\tFRAMEWORK_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Flutter\",\n\t\t\t\t);\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 26.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Flutter\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.otraku.app;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Runner/Runner-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\t97C146E91CF9000F007C117D /* Build configuration list for PBXProject \"Runner\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t97C147031CF9000F007C117D /* Debug */,\n\t\t\t\t97C147041CF9000F007C117D /* Release */,\n\t\t\t\t249021D3217E4FDB00AE95B9 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget \"Runner\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t97C147061CF9000F007C117D /* Debug */,\n\t\t\t\t97C147071CF9000F007C117D /* Release */,\n\t\t\t\t249021D4217E4FDB00AE95B9 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\t};\n\trootObject = 97C146E61CF9000F007C117D /* Project object */;\n}\n"
  },
  {
    "path": "ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PreviewsEnabled</key>\n\t<false/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1510\"\n   version = \"1.3\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n               BuildableName = \"Runner.app\"\n               BlueprintName = \"Runner\"\n               ReferencedContainer = \"container:Runner.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      customLLDBInitFile = \"$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\">\n      <MacroExpansion>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n            BuildableName = \"Runner.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </MacroExpansion>\n      <Testables>\n      </Testables>\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      customLLDBInitFile = \"$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      enableGPUValidationMode = \"1\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n            BuildableName = \"Runner.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Profile\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n            BuildableName = \"Runner.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "ios/Runner.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"group:Runner.xcodeproj\">\n   </FileRef>\n   <FileRef\n      location = \"group:Pods/Pods.xcodeproj\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PreviewsEnabled</key>\n\t<false/>\n</dict>\n</plist>\n"
  },
  {
    "path": "lib/extension/action_chip_extension.dart",
    "content": "import 'package:flutter/material.dart';\n\nextension ActionChipExtension on ActionChip {\n  static final highContrast = (bool highContrast) =>\n      highContrast ? ActionChip.new : ActionChip.elevated;\n}\n"
  },
  {
    "path": "lib/extension/build_context_extension.dart",
    "content": "import 'package:flutter/widgets.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\n\nextension BuildContextExtension on BuildContext {\n  void back() => canPop() ? pop() : go(Routes.home());\n\n  double lineHeight(TextStyle style) {\n    final scaler = MediaQuery.textScalerOf(this);\n    final scaled = scaler.scale(style.fontSize ?? Theming.fontMedium) * (style.height ?? 1);\n    return scaled.ceilToDouble();\n  }\n}\n"
  },
  {
    "path": "lib/extension/card_extension.dart",
    "content": "import 'package:flutter/material.dart';\n\nextension CardExtension on Card {\n  static final highContrast = (bool highContrast) => highContrast ? Card.outlined : Card.new;\n}\n"
  },
  {
    "path": "lib/extension/color_extension.dart",
    "content": "import 'package:flutter/widgets.dart';\n\nextension ColorExtension on Color {\n  static Color? fromHexString(String src) {\n    try {\n      return Color(int.parse(src.substring(1, 7), radix: 16) + 0xFF000000);\n    } catch (_) {\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "lib/extension/date_time_extension.dart",
    "content": "extension DateTimeExtension on DateTime {\n  int get secondsSinceEpoch => millisecondsSinceEpoch ~/ 1000;\n\n  static DateTime fromSecondsSinceEpoch(int seconds) =>\n      DateTime.fromMillisecondsSinceEpoch(seconds * 1000);\n\n  static DateTime? tryFromSecondsSinceEpoch(int? seconds) =>\n      seconds != null ? fromSecondsSinceEpoch(seconds) : null;\n\n  String formattedDateTimeFromSeconds(bool analogClock) =>\n      '${_weekdayName(weekday)}, $formattedDate, ${formattedTime(analogClock)}';\n\n  static DateTime? fromFuzzyDate(Map<String, dynamic>? map) {\n    if (map?['year'] == null) return null;\n    return DateTime(map!['year'], map['month'] ?? 1, map['day'] ?? 1);\n  }\n\n  static String? fuzzyDateString(Map<String, dynamic>? map) {\n    if (map == null || map['year'] == null) return null;\n\n    final year = map['year'];\n    final month = map['month'];\n    final day = map['day'];\n\n    return '${day != null ? '$day ' : ''}'\n        '${month != null ? '${monthName(month)} ' : ''}'\n        '$year';\n  }\n\n  Map<String, dynamic> get fuzzyDate => {'year': year, 'month': month, 'day': day};\n\n  String get formattedWithWeekDay => '$formattedDate - ${_weekdayName(weekday)}';\n\n  String get formattedDate => '$day ${monthName(month)} $year';\n\n  String formattedTime(bool analogClock) {\n    if (analogClock) {\n      final (overflows, realHour) = hour > 12 ? (true, hour - 12) : (false, hour);\n\n      return '${realHour < 10 ? 0 : ''}$realHour'\n          ':${minute < 10 ? 0 : ''}$minute '\n          '${overflows ? 'PM' : 'AM'}';\n    }\n\n    return '${hour <= 9 ? 0 : ''}$hour'\n        ':${minute <= 9 ? 0 : ''}$minute';\n  }\n\n  String get timeUntil {\n    int minutes = difference(DateTime.now()).inMinutes;\n    int hours = minutes ~/ 60;\n    minutes %= 60;\n    int days = hours ~/ 24;\n    hours %= 24;\n    return '${days < 1 ? \"\" : \"${days}d \"}'\n        '${hours < 1 ? \"\" : \"${hours}h \"}'\n        '${minutes < 1 ? \"\" : \"${minutes}m\"}';\n  }\n\n  static String monthName(int month) => switch (month) {\n    1 => 'Jan',\n    2 => 'Feb',\n    3 => 'Mar',\n    4 => 'Apr',\n    5 => 'May',\n    6 => 'Jun',\n    7 => 'Jul',\n    8 => 'Aug',\n    9 => 'Sep',\n    10 => 'Oct',\n    11 => 'Nov',\n    _ => 'Dec',\n  };\n\n  static String _weekdayName(int weekday) => switch (weekday) {\n    1 => 'Mon',\n    2 => 'Tue',\n    3 => 'Wed',\n    4 => 'Thu',\n    5 => 'Fri',\n    6 => 'Sat',\n    _ => 'Sun',\n  };\n}\n"
  },
  {
    "path": "lib/extension/enum_extension.dart",
    "content": "extension EnumExtension<T extends Enum> on Iterable<T> {\n  T? getOrNull(int? index) {\n    if (index != null && index >= 0 && index < length) {\n      return elementAt(index);\n    }\n\n    return null;\n  }\n\n  T getOrFirst(int? index) {\n    if (index != null && index >= 0 && index < length) {\n      return elementAt(index);\n    }\n\n    return first;\n  }\n}\n"
  },
  {
    "path": "lib/extension/filter_chip_extension.dart",
    "content": "import 'package:flutter/material.dart';\n\nextension FilterChipExtension on FilterChip {\n  static final highContrast = (bool highContrast) =>\n      highContrast ? FilterChip.new : FilterChip.elevated;\n}\n"
  },
  {
    "path": "lib/extension/future_extension.dart",
    "content": "extension FutureExtension on Future {\n  Future<Object?> getErrorOrNull() => then<Object?>((_) => null, onError: (e) => e);\n}\n"
  },
  {
    "path": "lib/extension/iterable_extension.dart",
    "content": "extension IterableExtension<E> on Iterable<E> {\n  E? firstWhereOrNull(bool Function(E) test) {\n    for (E element in this) {\n      if (test(element)) return element;\n    }\n    return null;\n  }\n}\n"
  },
  {
    "path": "lib/extension/scroll_controller_extension.dart",
    "content": "import 'package:flutter/widgets.dart';\n\nextension ScrollControllerExtension on ScrollController {\n  /// Scroll to the top with an animation.\n  Future<void> scrollToTop() async {\n    if (!hasClients || positions.last.pixels <= 0) return;\n\n    if (positions.last.pixels > 100) positions.last.jumpTo(100);\n\n    await positions.last.animateTo(\n      0,\n      duration: const Duration(milliseconds: 200),\n      curve: Curves.decelerate,\n    );\n  }\n}\n"
  },
  {
    "path": "lib/extension/snack_bar_extension.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:url_launcher/url_launcher.dart';\n\nextension SnackBarExtension on SnackBar {\n  static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> show(\n    BuildContext context,\n    String text, {\n    bool canCopyText = false,\n  }) {\n    return ScaffoldMessenger.of(context).showSnackBar(\n      SnackBar(\n        content: Text(text),\n        behavior: SnackBarBehavior.floating,\n        duration: const Duration(milliseconds: 2000),\n        persist: false,\n        action: canCopyText\n            ? SnackBarAction(\n                label: 'Copy',\n                onPressed: () => Clipboard.setData(ClipboardData(text: text)),\n              )\n            : null,\n      ),\n    );\n  }\n\n  /// Copy [text] to clipboard and notify with a snackbar.\n  static void copy(BuildContext context, String text) async {\n    await Clipboard.setData(ClipboardData(text: text));\n    if (context.mounted) show(context, 'Copied');\n  }\n\n  /// Launch [link] in the browser or show a snackbar if unsuccessful.\n  static Future<bool> launch(BuildContext context, String link) async {\n    try {\n      final ok = await launchUrl(\n        Uri.parse(link),\n        mode: link.startsWith(\"https://anilist.co\")\n            ? LaunchMode.inAppBrowserView\n            : LaunchMode.externalApplication,\n      );\n\n      if (ok) return true;\n    } catch (_) {}\n\n    if (context.mounted) show(context, 'Could not open link');\n    return false;\n  }\n}\n"
  },
  {
    "path": "lib/extension/string_extension.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\n\nextension StringExtension on String {\n  static String? languageToCode(String? language) => switch (language) {\n    'Japanese' => 'JP',\n    'Chinese' => 'CN',\n    'Korean' => 'KR',\n    'French' => 'FR',\n    'Spanish' => 'ES',\n    'Italian' => 'IT',\n    'Portuguese' => 'PT',\n    'German' => 'DE',\n    _ => null,\n  };\n\n  static String? tryNoScreamingSnakeCase(dynamic str) =>\n      str is String ? str.noScreamingSnakeCase : null;\n\n  static final _ampersand = '&'.codeUnitAt(0);\n  static final _hashtag = '#'.codeUnitAt(0);\n  static final _semicolon = ';'.codeUnitAt(0);\n\n  /// AniList can't handle some unicode characters, so before uploading text,\n  /// symbols that are too big should be represented as HTML character entity\n  /// references. Important primarily for emojis, hence the name.\n  String get withParsedEmojis {\n    final parsedRunes = <int>[];\n    for (final c in runes.toList()) {\n      if (c > 0xFFFF) {\n        parsedRunes.addAll([_ampersand, _hashtag, ...c.toString().codeUnits, _semicolon]);\n      } else {\n        parsedRunes.add(c);\n      }\n    }\n\n    return String.fromCharCodes(parsedRunes);\n  }\n\n  String get noScreamingSnakeCase => splitMapJoin(\n    '_',\n    onMatch: (_) => ' ',\n    onNonMatch: (s) => s[0].toUpperCase() + s.substring(1).toLowerCase(),\n  );\n\n  static String? fromFuzzyDate(Map<String, dynamic>? map) {\n    if (map?['year'] == null) return null;\n    final year = map!['year'];\n    final month = map['month'];\n    final day = map['day'];\n    return '${day != null ? '$day ' : ''}${month != null ? '${DateTimeExtension.monthName(month)} ' : ''}$year';\n  }\n}\n"
  },
  {
    "path": "lib/feature/activity/activities_filter_model.dart",
    "content": "import 'package:otraku/extension/enum_extension.dart';\n\nsealed class ActivitiesFilter {\n  const ActivitiesFilter();\n\n  ActivitiesFilter copy();\n\n  Map<String, dynamic> toGraphQlVariables();\n}\n\nclass HomeActivitiesFilter extends ActivitiesFilter {\n  const HomeActivitiesFilter(\n    this.viewerId,\n    this.onFollowing,\n    this.withViewerActivities,\n    this.typeIn,\n  );\n\n  factory HomeActivitiesFilter.empty() =>\n      const HomeActivitiesFilter(null, false, false, [.animeStatus, .mangaStatus, .status]);\n\n  factory HomeActivitiesFilter.fromPersistenceMap(Map<dynamic, dynamic> map, int? viewerId) {\n    final List<int> typeIn =\n        map['activityTypeIn'] ??\n        [ActivityType.status.index, ActivityType.animeStatus.index, ActivityType.mangaStatus.index];\n\n    return HomeActivitiesFilter(\n      viewerId,\n      map['onFollowing'] ?? false,\n      map['withViewerActivities'] ?? false,\n      typeIn.map((index) => ActivityType.values.getOrFirst(index)).toList(),\n    );\n  }\n\n  final int? viewerId;\n  final bool onFollowing;\n  final bool withViewerActivities;\n  final List<ActivityType> typeIn;\n\n  @override\n  HomeActivitiesFilter copy() =>\n      HomeActivitiesFilter(viewerId, onFollowing, withViewerActivities, [...typeIn]);\n\n  HomeActivitiesFilter copyWith({\n    bool? onFollowing,\n    bool? withViewerActivities,\n    List<ActivityType>? typeIn,\n  }) => HomeActivitiesFilter(\n    viewerId,\n    onFollowing ?? this.onFollowing,\n    withViewerActivities ?? this.withViewerActivities,\n    typeIn ?? this.typeIn,\n  );\n\n  @override\n  Map<String, dynamic> toGraphQlVariables() => {\n    'isFollowing': onFollowing,\n    if (!onFollowing) 'hasRepliesOrText': true,\n    if (!withViewerActivities && viewerId != null) 'userIdNot': viewerId,\n    'typeIn': typeIn.map((t) => t.value).toList(),\n  };\n\n  Map<String, dynamic> toPersistenceMap() => {\n    'onFollowing': onFollowing,\n    'withViewerActivities': withViewerActivities,\n    'activityTypeIn': typeIn.map((a) => a.index).toList(),\n  };\n}\n\nclass UserActivitiesFilter extends ActivitiesFilter {\n  const UserActivitiesFilter(this.userId, this.typeIn);\n\n  final int userId;\n  final List<ActivityType> typeIn;\n\n  @override\n  UserActivitiesFilter copy() => UserActivitiesFilter(userId, [...typeIn]);\n\n  UserActivitiesFilter copyWithTypeIn(List<ActivityType> typeIn) =>\n      UserActivitiesFilter(userId, typeIn);\n\n  @override\n  Map<String, dynamic> toGraphQlVariables() => {\n    'userId': userId,\n    'typeIn': typeIn.map((t) => t.value).toList(),\n  };\n}\n\nclass MediaActivitiesFilter extends ActivitiesFilter {\n  const MediaActivitiesFilter(this.socialGroup, this.mediaId, this.viewerId);\n\n  factory MediaActivitiesFilter.empty() => const MediaActivitiesFilter(.global, 0, null);\n\n  final int mediaId;\n  final ActivitySocialGroup socialGroup;\n  final int? viewerId;\n\n  @override\n  MediaActivitiesFilter copy() => MediaActivitiesFilter(socialGroup, mediaId, viewerId);\n\n  MediaActivitiesFilter copyWith({\n    ActivitySocialGroup? socialGroup,\n    int? mediaId,\n    (int?,)? viewerId,\n  }) => MediaActivitiesFilter(\n    socialGroup ?? this.socialGroup,\n    mediaId ?? this.mediaId,\n    viewerId != null ? viewerId.$1 : this.viewerId,\n  );\n\n  @override\n  Map<String, dynamic> toGraphQlVariables() => {\n    'mediaId': mediaId,\n    ...switch (socialGroup) {\n      .global => const {},\n      .followed => const {'isFollowing': true},\n      .self => viewerId != null ? {'userId': viewerId} : {'isFollowing': true},\n    },\n  };\n\n  Map<String, dynamic> toPersistenceMap() => {'socialGroup': socialGroup.index};\n\n  static MediaActivitiesFilter fromPersistence(\n    Map<dynamic, dynamic> map,\n    int mediaId,\n    int? viewerId,\n  ) => MediaActivitiesFilter(\n    ActivitySocialGroup.values.getOrFirst(map['socialGroup']),\n    mediaId,\n    viewerId,\n  );\n}\n\nenum ActivityType {\n  status('Statuses', 'TEXT'),\n  animeStatus('Anime Progress', 'ANIME_LIST'),\n  mangaStatus('Manga Progress', 'MANGA_LIST'),\n  message('Messages', 'MESSAGE');\n\n  const ActivityType(this.label, this.value);\n\n  final String label;\n  final String value;\n}\n\nenum ActivitySocialGroup { global, followed, self }\n"
  },
  {
    "path": "lib/feature/activity/activities_filter_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/activity/activities_filter_model.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\n\nfinal activitiesFilterProvider = NotifierProvider.autoDispose\n    .family<ActivitiesFilterNotifier, ActivitiesFilter, ActivitiesTag>(\n      ActivitiesFilterNotifier.new,\n    );\n\nclass ActivitiesFilterNotifier extends Notifier<ActivitiesFilter> {\n  ActivitiesFilterNotifier(this.arg);\n\n  final ActivitiesTag arg;\n\n  @override\n  ActivitiesFilter build() => switch (arg) {\n    HomeActivitiesTag _ => ref.watch(persistenceProvider.select((s) => s.homeActivitiesFilter)),\n    UserActivitiesTag(:final userId) => UserActivitiesFilter(userId, ActivityType.values),\n    MediaActivitiesTag(:final mediaId) =>\n      ref\n          .watch(persistenceProvider.select((s) => s.mediaActivitiesFilter))\n          .copyWith(mediaId: mediaId, viewerId: (ref.watch(viewerIdProvider),)),\n  };\n\n  @override\n  set state(ActivitiesFilter newState) {\n    if (state == newState) return;\n\n    switch (newState) {\n      case HomeActivitiesFilter homeActivitiesFilter:\n        ref.read(persistenceProvider.notifier).setHomeActivitiesFilter(homeActivitiesFilter);\n      case MediaActivitiesFilter mediaActivitiesFilter:\n        ref.read(persistenceProvider.notifier).setMediaActivitiesFilter(mediaActivitiesFilter);\n      case UserActivitiesFilter _:\n        super.state = newState;\n    }\n  }\n}\n"
  },
  {
    "path": "lib/feature/activity/activities_model.dart",
    "content": "sealed class ActivitiesTag {\n  const ActivitiesTag();\n\n  String toQueryParam() => switch (this) {\n    HomeActivitiesTag() => 'home',\n    UserActivitiesTag(:final userId) => 'user:$userId',\n    MediaActivitiesTag(:final mediaId) => 'media:$mediaId',\n  };\n\n  static ActivitiesTag? fromQueryParam(String param) {\n    if (param == 'home') {\n      return HomeActivitiesTag.instance;\n    } else if (param.startsWith('user:')) {\n      final userId = int.tryParse(param.substring(5));\n      if (userId != null) {\n        return UserActivitiesTag(userId);\n      }\n    } else if (param.startsWith('media:')) {\n      final mediaId = int.tryParse(param.substring(6));\n      if (mediaId != null) {\n        return MediaActivitiesTag(mediaId);\n      }\n    }\n\n    return null;\n  }\n}\n\nclass HomeActivitiesTag extends ActivitiesTag {\n  const HomeActivitiesTag._();\n\n  static const instance = HomeActivitiesTag._();\n}\n\nclass UserActivitiesTag extends ActivitiesTag {\n  const UserActivitiesTag(this.userId);\n\n  final int userId;\n\n  @override\n  bool operator ==(Object other) => other is UserActivitiesTag && userId == other.userId;\n\n  @override\n  int get hashCode => userId.hashCode;\n}\n\nclass MediaActivitiesTag extends ActivitiesTag {\n  const MediaActivitiesTag(this.mediaId);\n\n  final int mediaId;\n\n  @override\n  bool operator ==(Object other) => other is MediaActivitiesTag && mediaId == other.mediaId;\n\n  @override\n  int get hashCode => mediaId.hashCode;\n}\n"
  },
  {
    "path": "lib/feature/activity/activities_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/feature/activity/activities_filter_model.dart';\nimport 'package:otraku/feature/activity/activities_filter_provider.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/feature/activity/activity_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal activitiesProvider = AsyncNotifierProvider.autoDispose\n    .family<ActivitiesNotifier, Paged<Activity>, ActivitiesTag>(ActivitiesNotifier.new);\n\nclass ActivitiesNotifier extends AsyncNotifier<Paged<Activity>> {\n  ActivitiesNotifier(this.arg);\n\n  final ActivitiesTag arg;\n\n  int? _viewerId;\n  late ActivitiesFilter _filter;\n\n  // Used to skip activities when fetching outdated pages.\n  int? _lastId;\n\n  @override\n  FutureOr<Paged<Activity>> build() {\n    // The home feed and the media feeds are lazy-loaded. The home feed is never disposed,\n    // while the media feeds are disposed only when the media page is popped.\n    if (arg is HomeActivitiesTag || arg is MediaActivitiesTag) {\n      ref.keepAlive();\n    }\n\n    _lastId = null;\n    _filter = ref.watch(activitiesFilterProvider(arg));\n    _viewerId = ref.watch(viewerIdProvider);\n\n    return _fetch(const Paged());\n  }\n\n  Future<void> fetch() async {\n    final oldState = state.value ?? const Paged();\n    if (!oldState.hasNext) return;\n    state = await AsyncValue.guard(() => _fetch(oldState));\n  }\n\n  Future<Paged<Activity>> _fetch(Paged<Activity> oldState) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.activityPage, {\n      'page': oldState.next,\n      ..._filter.toGraphQlVariables(),\n    });\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    final items = <Activity>[];\n    for (final a in data['Page']['activities']) {\n      if (_lastId != null && a['id'] >= _lastId) continue;\n\n      final item = Activity.maybe(a, _viewerId, imageQuality);\n      if (item != null) items.add(item);\n    }\n\n    if (data['Page']['activities'].isNotEmpty) {\n      _lastId = data['Page']['activities'].last['id'];\n    }\n\n    return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false);\n  }\n\n  void prepend(Map<String, dynamic> map) {\n    final value = state.value;\n    if (value == null) return;\n\n    final activity = Activity.maybe(\n      map,\n      _viewerId,\n      ref.read(persistenceProvider).options.imageQuality,\n    );\n    if (activity == null) return;\n\n    state = AsyncValue.data(\n      Paged(items: [activity, ...value.items], hasNext: value.hasNext, next: value.next),\n    );\n  }\n\n  void replace(Activity activity) {\n    final value = state.value;\n    if (value == null) return;\n\n    for (int i = 0; i < value.items.length; i++) {\n      if (value.items[i].id == activity.id) {\n        value.items[i] = activity;\n\n        state = AsyncValue.data(\n          Paged(items: value.items, hasNext: value.hasNext, next: value.next),\n        );\n        return;\n      }\n    }\n  }\n\n  Future<Object?> toggleLike(Activity activity) async {\n    final err = await ref.read(repositoryProvider).request(GqlMutation.toggleLike, {\n      'id': activity.id,\n      'type': 'ACTIVITY',\n    }).getErrorOrNull();\n\n    if (err != null) return err;\n\n    replace(activity);\n    return null;\n  }\n\n  Future<Object?> toggleSubscription(Activity activity) async {\n    final err = await ref.read(repositoryProvider).request(GqlMutation.toggleActivitySubscription, {\n      'id': activity.id,\n      'subscribe': activity.isSubscribed,\n    }).getErrorOrNull();\n\n    if (err != null) return err;\n\n    replace(activity);\n    return null;\n  }\n\n  Future<Object?> togglePin(Activity activity) async {\n    final err = await ref.read(repositoryProvider).request(GqlMutation.toggleActivityPin, {\n      'id': activity.id,\n      'pinned': activity.isPinned,\n    }).getErrorOrNull();\n\n    if (err != null) return err;\n\n    final value = state.value;\n    if (value == null) return null;\n\n    for (int i = 0; i < value.items.length; i++) {\n      if (value.items[i].id == activity.id) {\n        // Unpin previously pinned activity.\n        if (value.items.length > 1) {\n          value.items[0].isPinned = false;\n        }\n\n        // Move newly pinned activity to the top.\n        for (int j = i - 1; j >= 0; j--) {\n          value.items[j + 1] = value.items[j];\n        }\n        value.items[0] = activity;\n\n        state = AsyncValue.data(\n          Paged(items: value.items, hasNext: value.hasNext, next: value.next),\n        );\n        break;\n      }\n    }\n\n    return null;\n  }\n\n  Future<Object?> remove(Activity activity) async {\n    final err = await ref.read(repositoryProvider).request(GqlMutation.deleteActivity, {\n      'id': activity.id,\n    }).getErrorOrNull();\n\n    if (err != null) return err;\n\n    final value = state.value;\n    if (value == null) return null;\n\n    for (int i = 0; i < value.items.length; i++) {\n      if (value.items[i].id == activity.id) {\n        value.items.removeAt(i);\n\n        state = AsyncValue.data(\n          Paged(items: value.items, hasNext: value.hasNext, next: value.next),\n        );\n        break;\n      }\n    }\n\n    return null;\n  }\n}\n"
  },
  {
    "path": "lib/feature/activity/activities_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/feature/activity/activity_filter_sheet.dart';\nimport 'package:otraku/feature/activity/activities_provider.dart';\nimport 'package:otraku/feature/activity/activity_card.dart';\nimport 'package:otraku/feature/composition/composition_model.dart';\nimport 'package:otraku/feature/composition/composition_view.dart';\nimport 'package:otraku/feature/settings/settings_provider.dart';\nimport 'package:otraku/feature/activity/activity_model.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/widget/paged_view.dart';\n\nclass ActivitiesView extends ConsumerStatefulWidget {\n  const ActivitiesView(this.tag);\n\n  final UserActivitiesTag tag;\n\n  @override\n  ConsumerState<ActivitiesView> createState() => _ActivitiesViewState();\n}\n\nclass _ActivitiesViewState extends ConsumerState<ActivitiesView> {\n  late final _scrollCtrl = PagedController(\n    loadMore: () => ref.read(activitiesProvider(widget.tag).notifier).fetch(),\n  );\n\n  @override\n  void dispose() {\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final viewerId = ref.watch(viewerIdProvider);\n    final userId = widget.tag.userId;\n\n    final floatingAction = viewerId != null\n        ? HidingFloatingActionButton(\n            key: const Key('post'),\n            scrollCtrl: _scrollCtrl,\n            child: FloatingActionButton(\n              tooltip: userId == viewerId ? 'New Post' : 'New Message',\n              child: const Icon(Icons.edit_outlined),\n              onPressed: () => showSheet(\n                context,\n                CompositionView(\n                  tag: userId == viewerId\n                      ? const StatusActivityCompositionTag(id: null)\n                      : MessageActivityCompositionTag(id: null, recipientId: userId),\n                  onSaved: (map) => ref.read(activitiesProvider(widget.tag).notifier).prepend(map),\n                ),\n              ),\n            ),\n          )\n        : null;\n\n    return AdaptiveScaffold(\n      topBar: TopBar(\n        title: 'Activities',\n        trailing: [\n          IconButton(\n            tooltip: 'Filter',\n            icon: const Icon(Ionicons.funnel_outline),\n            onPressed: () => showActivityFilterSheet(context, ref, widget.tag),\n          ),\n        ],\n      ),\n      floatingAction: floatingAction,\n      child: ActivitiesSubView(widget.tag, _scrollCtrl),\n    );\n  }\n}\n\nclass ActivitiesSubView extends StatelessWidget {\n  const ActivitiesSubView(this.tag, this.scrollCtrl);\n\n  final ActivitiesTag tag;\n  final ScrollController scrollCtrl;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        final viewerId = ref.watch(viewerIdProvider);\n        final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n        return PagedView<Activity>(\n          provider: activitiesProvider(\n            tag,\n          ).select((s) => s.unwrapPrevious().whenData((data) => data)),\n          scrollCtrl: scrollCtrl,\n          onRefresh: (invalidate) {\n            invalidate(activitiesProvider(tag));\n            if (tag is HomeActivitiesTag) {\n              ref.read(settingsProvider.notifier).refetchUnread();\n            }\n          },\n          onData: (data) => SliverList(\n            delegate: SliverChildBuilderDelegate(\n              childCount: data.items.length,\n              (context, i) => ActivityCard(\n                withHeader: true,\n                analogClock: options.analogClock,\n                highContrast: options.highContrast,\n                activity: data.items[i],\n                footer: ActivityFooter(\n                  viewerId: viewerId,\n                  activity: data.items[i],\n                  toggleLike: () =>\n                      ref.read(activitiesProvider(tag).notifier).toggleLike(data.items[i]),\n                  toggleSubscription: () =>\n                      ref.read(activitiesProvider(tag).notifier).toggleSubscription(data.items[i]),\n                  togglePin: () =>\n                      ref.read(activitiesProvider(tag).notifier).togglePin(data.items[i]),\n                  remove: () => ref.read(activitiesProvider(tag).notifier).remove(data.items[i]),\n                  onEdited: (map) {\n                    final activity = Activity.maybe(map, viewerId, options.imageQuality);\n\n                    if (activity == null) return;\n\n                    ref.read(activitiesProvider(tag).notifier).replace(activity);\n                  },\n                  reply: () => context.push(Routes.activity(data.items[i].id, tag)),\n                ),\n              ),\n            ),\n          ),\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/activity/activity_card.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/activity/activity_model.dart';\nimport 'package:otraku/feature/composition/composition_model.dart';\nimport 'package:otraku/feature/composition/composition_view.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/widget/timestamp.dart';\n\nclass ActivityCard extends StatelessWidget {\n  const ActivityCard({\n    required this.activity,\n    required this.footer,\n    required this.withHeader,\n    required this.analogClock,\n    required this.highContrast,\n  });\n\n  final Activity activity;\n  final ActivityFooter footer;\n  final bool withHeader;\n  final bool analogClock;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final body = CardExtension.highContrast(highContrast)(\n      margin: const .only(bottom: Theming.offset),\n      child: Padding(\n        padding: const .only(top: Theming.offset, left: Theming.offset, right: Theming.offset),\n        child: Column(\n          children: [\n            if (activity is MediaActivity)\n              _ActivityMediaBox(activity as MediaActivity)\n            else\n              HtmlContent(activity.text),\n            Row(\n              mainAxisAlignment: .spaceBetween,\n              spacing: 5,\n              children: [\n                Flexible(child: Timestamp(activity.createdAt, analogClock)),\n                footer,\n              ],\n            ),\n          ],\n        ),\n      ),\n    );\n\n    if (!withHeader) return body;\n\n    const avatarSize = 50.0;\n\n    return Column(\n      crossAxisAlignment: .start,\n      children: [\n        Row(\n          children: [\n            Flexible(\n              child: GestureDetector(\n                behavior: .opaque,\n                onTap: () => context.push(Routes.user(activity.authorId, activity.authorAvatarUrl)),\n                child: Row(\n                  mainAxisSize: .min,\n                  spacing: Theming.offset,\n                  children: [\n                    ClipRRect(\n                      borderRadius: Theming.borderRadiusSmall,\n                      child: CachedImage(\n                        activity.authorAvatarUrl,\n                        height: avatarSize,\n                        width: avatarSize,\n                      ),\n                    ),\n                    Flexible(child: Text(activity.authorName, overflow: .ellipsis, maxLines: 1)),\n                  ],\n                ),\n              ),\n            ),\n            ...switch (activity) {\n              MessageActivity message => [\n                if (message.isPrivate)\n                  const Padding(\n                    padding: .only(left: Theming.offset),\n                    child: Icon(Ionicons.eye_off_outline),\n                  ),\n                const Padding(\n                  padding: .symmetric(horizontal: Theming.offset),\n                  child: Icon(Icons.arrow_right_alt),\n                ),\n                GestureDetector(\n                  behavior: .opaque,\n                  onTap: () =>\n                      context.push(Routes.user(message.recipientId, message.recipientAvatarUrl)),\n                  child: ClipRRect(\n                    borderRadius: Theming.borderRadiusSmall,\n                    child: CachedImage(\n                      message.recipientAvatarUrl,\n                      height: avatarSize,\n                      width: avatarSize,\n                    ),\n                  ),\n                ),\n              ],\n              _ when activity.isPinned => const [\n                Padding(\n                  padding: .only(left: Theming.offset),\n                  child: Icon(Icons.push_pin_outlined),\n                ),\n              ],\n              _ => const [],\n            },\n          ],\n        ),\n        const SizedBox(height: 5),\n        body,\n      ],\n    );\n  }\n}\n\nclass _ActivityMediaBox extends StatelessWidget {\n  const _ActivityMediaBox(this.item);\n\n  final MediaActivity item;\n\n  @override\n  Widget build(BuildContext context) {\n    final textTheme = TextTheme.of(context);\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);\n    final height = bodyMediumLineHeight * 3 + labelMediumLineHeight + 5;\n\n    return MediaRouteTile(\n      id: item.mediaId,\n      imageUrl: item.coverUrl,\n      child: SizedBox(\n        height: height,\n        child: Row(\n          children: [\n            ClipRRect(\n              borderRadius: Theming.borderRadiusSmall,\n              child: CachedImage(item.coverUrl, width: height / Theming.coverHtoWRatio),\n            ),\n            Expanded(\n              child: Padding(\n                padding: const .symmetric(horizontal: Theming.offset),\n                child: Column(\n                  mainAxisAlignment: .spaceEvenly,\n                  crossAxisAlignment: .start,\n                  spacing: 5,\n                  children: [\n                    Text.rich(\n                      TextSpan(\n                        children: [\n                          TextSpan(text: item.text, style: textTheme.labelMedium),\n                          TextSpan(text: item.title, style: textTheme.bodyMedium),\n                        ],\n                      ),\n                      overflow: .ellipsis,\n                      maxLines: 3,\n                    ),\n                    if (item.format != null)\n                      Text(\n                        item.format!,\n                        style: textTheme.labelMedium,\n                        overflow: .ellipsis,\n                        maxLines: 1,\n                      ),\n                  ],\n                ),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n\nclass ActivityFooter extends StatefulWidget {\n  const ActivityFooter({\n    required this.viewerId,\n    required this.activity,\n    required this.remove,\n    required this.togglePin,\n    required this.toggleLike,\n    required this.toggleSubscription,\n    required this.reply,\n    required this.onEdited,\n  });\n\n  final int? viewerId;\n  final Activity activity;\n  final Future<Object?> Function() remove;\n  final Future<Object?> Function() toggleLike;\n  final Future<Object?> Function() toggleSubscription;\n  final Future<Object?> Function() togglePin;\n  final Future<Object?> Function()? reply;\n  final void Function(Map<String, dynamic>)? onEdited;\n\n  @override\n  State<ActivityFooter> createState() => _ActivityFooterState();\n}\n\nclass _ActivityFooterState extends State<ActivityFooter> {\n  @override\n  Widget build(BuildContext context) {\n    final activity = widget.activity;\n\n    return Row(\n      children: [\n        SizedBox(\n          height: 40,\n          child: Tooltip(\n            message: 'More',\n            child: InkResponse(\n              radius: Theming.radiusSmall.x,\n              onTap: _showMoreSheet,\n              child: const Icon(Ionicons.ellipsis_horizontal, size: Theming.iconSmall),\n            ),\n          ),\n        ),\n        const SizedBox(width: Theming.offset),\n        SizedBox(\n          height: 40,\n          child: Tooltip(\n            message: 'Replies',\n            child: InkResponse(\n              radius: Theming.radiusSmall.x,\n              onTap: widget.reply,\n              child: Row(\n                children: [\n                  Text(activity.replyCount.toString(), style: TextTheme.of(context).labelSmall),\n                  const SizedBox(width: 5),\n                  const Icon(Icons.reply_all_rounded, size: Theming.iconSmall),\n                ],\n              ),\n            ),\n          ),\n        ),\n        const SizedBox(width: Theming.offset),\n        SizedBox(\n          height: 40,\n          child: Tooltip(\n            message: !activity.isLiked ? 'Like' : 'Unlike',\n            child: InkResponse(\n              radius: Theming.radiusSmall.x,\n              onTap: _toggleLike,\n              child: Row(\n                children: [\n                  Text(\n                    activity.likeCount.toString(),\n                    style: !activity.isLiked\n                        ? TextTheme.of(context).labelSmall\n                        : TextTheme.of(\n                            context,\n                          ).labelSmall!.copyWith(color: ColorScheme.of(context).primary),\n                  ),\n                  const SizedBox(width: 5),\n                  Icon(\n                    !widget.activity.isLiked\n                        ? Icons.favorite_outline_rounded\n                        : Icons.favorite_rounded,\n                    size: Theming.iconSmall,\n                    color: activity.isLiked ? ColorScheme.of(context).primary : null,\n                  ),\n                ],\n              ),\n            ),\n          ),\n        ),\n      ],\n    );\n  }\n\n  /// Show a sheet with additional options.\n  void _showMoreSheet() {\n    final activity = widget.activity;\n\n    showSheet(\n      context,\n      Consumer(\n        builder: (context, ref, _) {\n          final ownershipButtons = <Widget>[];\n\n          if (activity.isOwned) {\n            if (activity is! MessageActivity) {\n              ownershipButtons.add(\n                ListTile(\n                  title: activity.isPinned ? const Text('Unpin') : const Text('Pin'),\n                  leading: activity.isPinned\n                      ? const Icon(Icons.push_pin)\n                      : const Icon(Icons.push_pin_outlined),\n                  onTap: _togglePin,\n                ),\n              );\n            }\n\n            if (activity.authorId == widget.viewerId) {\n              switch (activity) {\n                case StatusActivity _:\n                  ownershipButtons.add(\n                    ListTile(\n                      title: const Text('Edit'),\n                      leading: const Icon(Icons.edit_outlined),\n                      onTap: () => showSheet(\n                        context,\n                        CompositionView(\n                          tag: StatusActivityCompositionTag(id: activity.id),\n                          onSaved: (map) {\n                            widget.onEdited?.call(map);\n                            Navigator.pop(context);\n                          },\n                        ),\n                      ),\n                    ),\n                  );\n                case MessageActivity _:\n                  ownershipButtons.add(\n                    ListTile(\n                      title: const Text('Edit'),\n                      leading: const Icon(Icons.edit_outlined),\n                      onTap: () => showSheet(\n                        context,\n                        CompositionView(\n                          tag: MessageActivityCompositionTag(\n                            id: activity.id,\n                            recipientId: activity.recipientId,\n                          ),\n                          onSaved: (map) {\n                            widget.onEdited?.call(map);\n                            Navigator.pop(context);\n                          },\n                        ),\n                      ),\n                    ),\n                  );\n                case MediaActivity _:\n                  break;\n              }\n            }\n\n            ownershipButtons.add(\n              ListTile(\n                title: const Text('Delete'),\n                leading: const Icon(Ionicons.trash_outline),\n                onTap: () => ConfirmationDialog.show(\n                  context,\n                  title: 'Delete?',\n                  primaryAction: 'Yes',\n                  secondaryAction: 'No',\n                  onConfirm: _remove,\n                ),\n              ),\n            );\n          }\n\n          return SimpleSheet.link(context, activity.siteUrl, [\n            ...ownershipButtons,\n            ListTile(\n              title: !activity.isSubscribed ? const Text('Subscribe') : const Text('Unsubscribe'),\n              leading: !activity.isSubscribed\n                  ? const Icon(Ionicons.notifications_outline)\n                  : const Icon(Ionicons.notifications_off_outline),\n              onTap: _toggleSubscription,\n            ),\n          ]);\n        },\n      ),\n    );\n  }\n\n  void _toggleLike() async {\n    final activity = widget.activity;\n    final isLiked = activity.isLiked;\n\n    setState(() {\n      activity.isLiked = !isLiked;\n      activity.likeCount += isLiked ? -1 : 1;\n    });\n\n    final err = await widget.toggleLike();\n    if (err == null) return;\n\n    setState(() {\n      activity.isLiked = isLiked;\n      activity.likeCount += isLiked ? 1 : -1;\n    });\n\n    if (mounted) SnackBarExtension.show(context, err.toString());\n  }\n\n  void _toggleSubscription() {\n    final activity = widget.activity;\n    activity.isSubscribed = !activity.isSubscribed;\n\n    widget.toggleSubscription().then((err) {\n      if (err == null) {\n        if (mounted) Navigator.pop(context);\n        return;\n      }\n\n      activity.isSubscribed = !activity.isSubscribed;\n      if (mounted) {\n        SnackBarExtension.show(context, err.toString());\n        Navigator.pop(context);\n      }\n    });\n  }\n\n  void _togglePin() {\n    final activity = widget.activity;\n    activity.isPinned = !activity.isPinned;\n\n    widget.togglePin().then((err) {\n      if (err == null) {\n        if (mounted) Navigator.pop(context);\n        return;\n      }\n\n      activity.isPinned = !activity.isPinned;\n      if (mounted) {\n        SnackBarExtension.show(context, err.toString());\n        Navigator.pop(context);\n      }\n    });\n  }\n\n  void _remove() {\n    widget.remove().then((err) {\n      if (err == null) {\n        if (mounted) Navigator.pop(context);\n        return;\n      }\n\n      if (mounted) {\n        SnackBarExtension.show(context, err.toString());\n        Navigator.pop(context);\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "lib/feature/activity/activity_filter_sheet.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/activity/activities_filter_model.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/feature/activity/activities_filter_provider.dart';\n\nvoid showActivityFilterSheet(BuildContext context, WidgetRef ref, ActivitiesTag tag) {\n  ActivitiesFilter filter = ref.read(activitiesFilterProvider(tag));\n  double initialHeight = Theming.normalTapTarget * ActivityType.values.length + Theming.offset;\n\n  if (filter is HomeActivitiesFilter) {\n    initialHeight += Theming.normalTapTarget * 2.5;\n  }\n\n  showSheet(\n    context,\n    SimpleSheet(\n      initialHeight: initialHeight,\n      builder: (context, scrollCtrl) =>\n          _FilterList(filter: filter, onChanged: (v) => filter = v, scrollCtrl: scrollCtrl),\n    ),\n  ).then((_) {\n    ref.read(activitiesFilterProvider(tag).notifier).state = filter;\n  });\n}\n\nclass _FilterList extends StatefulWidget {\n  const _FilterList({required this.filter, required this.onChanged, required this.scrollCtrl});\n\n  final ActivitiesFilter filter;\n  final void Function(ActivitiesFilter) onChanged;\n  final ScrollController scrollCtrl;\n\n  @override\n  State<_FilterList> createState() => _FilterListState();\n}\n\nclass _FilterListState extends State<_FilterList> {\n  late var _filter = widget.filter.copy();\n\n  @override\n  Widget build(BuildContext context) {\n    final typeIn = switch (_filter) {\n      HomeActivitiesFilter(:final typeIn) => typeIn,\n      UserActivitiesFilter(:final typeIn) => typeIn,\n      MediaActivitiesFilter _ => [],\n    };\n\n    return ListView(\n      controller: widget.scrollCtrl,\n      physics: Theming.bouncyPhysics,\n      padding: const .symmetric(vertical: Theming.offset),\n      children: [\n        for (final a in ActivityType.values)\n          CheckboxListTile(\n            title: Text(a.label),\n            value: typeIn.contains(a),\n            onChanged: (val) {\n              setState(() {\n                if (val == true) {\n                  typeIn.add(a);\n                } else if (val == false) {\n                  typeIn.remove(a);\n                }\n              });\n\n              widget.onChanged(_filter.copy());\n            },\n          ),\n        ...switch (_filter) {\n          UserActivitiesFilter _ || MediaActivitiesFilter _ => const [],\n          HomeActivitiesFilter filter => [\n            const Divider(),\n            CheckboxListTile(\n              title: const Text('My Activities'),\n              value: filter.withViewerActivities,\n              onChanged: (v) {\n                setState(() => _filter = filter.copyWith(withViewerActivities: v!));\n\n                widget.onChanged(_filter.copy());\n              },\n            ),\n            Padding(\n              padding: const .only(\n                top: Theming.offset,\n                left: Theming.offset,\n                right: Theming.offset,\n              ),\n              child: SegmentedButton(\n                segments: const [\n                  ButtonSegment(\n                    value: true,\n                    label: Text('Following'),\n                    icon: Icon(Ionicons.people_outline),\n                  ),\n                  ButtonSegment(\n                    value: false,\n                    label: Text('Global'),\n                    icon: Icon(Ionicons.planet_outline),\n                  ),\n                ],\n                selected: {filter.onFollowing},\n                onSelectionChanged: (v) {\n                  setState(() => _filter = filter.copyWith(onFollowing: v.first));\n\n                  widget.onChanged(_filter.copy());\n                },\n              ),\n            ),\n          ],\n        },\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/activity/activity_model.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/util/markdown.dart';\n\nclass ExpandedActivity {\n  ExpandedActivity(this.activity, this.replies);\n\n  final Activity activity;\n  final Paged<ActivityReply> replies;\n}\n\nsealed class Activity {\n  Activity({\n    required this.id,\n    required this.authorId,\n    required this.authorName,\n    required this.authorAvatarUrl,\n    required this.createdAt,\n    required this.text,\n    required this.siteUrl,\n    required this.isOwned,\n    required this.replyCount,\n    required this.likeCount,\n    required this.isLiked,\n    required this.isSubscribed,\n    required this.isPinned,\n  });\n\n  static Activity? maybe(Map<String, dynamic> map, int? viewerId, ImageQuality imageQuality) {\n    try {\n      switch (map['type']) {\n        case 'TEXT':\n          if (map['user'] == null) return null;\n\n          return StatusActivity(\n            id: map['id'],\n            authorId: map['user']['id'],\n            authorName: map['user']['name'],\n            authorAvatarUrl: map['user']['avatar']['large'],\n            siteUrl: map['siteUrl'],\n            text: parseMarkdown(map['text'] ?? ''),\n            createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),\n            isOwned: map['user']['id'] == viewerId,\n            replyCount: map['replyCount'] ?? 0,\n            likeCount: map['likeCount'] ?? 0,\n            isLiked: map['isLiked'] ?? false,\n            isSubscribed: map['isSubscribed'] ?? false,\n            isPinned: map['isPinned'] ?? false,\n          );\n        case 'MESSAGE':\n          if (map['messenger'] == null || map['recipient'] == null) return null;\n\n          return MessageActivity(\n            id: map['id'],\n            authorId: map['messenger']['id'],\n            authorName: map['messenger']['name'],\n            authorAvatarUrl: map['messenger']['avatar']['large'],\n            recipientId: map['recipient']['id'],\n            recipientName: map['recipient']['name'],\n            recipientAvatarUrl: map['recipient']['avatar']['large'],\n            siteUrl: map['siteUrl'],\n            text: parseMarkdown(map['message'] ?? ''),\n            createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),\n            isOwned: map['messenger']['id'] == viewerId || map['recipient']['id'] == viewerId,\n            isPrivate: map['isPrivate'] ?? false,\n            replyCount: map['replyCount'] ?? 0,\n            likeCount: map['likeCount'] ?? 0,\n            isLiked: map['isLiked'] ?? false,\n            isSubscribed: map['isSubscribed'] ?? false,\n            isPinned: false,\n          );\n        case 'ANIME_LIST':\n        case 'MANGA_LIST':\n          if (map['user'] == null || map['media'] == null) return null;\n\n          final progress = map['progress'] != null ? '${map['progress']} of ' : '';\n          final status =\n              (map['status'] as String)[0].toUpperCase() + (map['status'] as String).substring(1);\n\n          return MediaActivity(\n            id: map['id'],\n            authorId: map['user']['id'],\n            authorName: map['user']['name'],\n            authorAvatarUrl: map['user']['avatar']['large'],\n            mediaId: map['media']['id'],\n            title: map['media']['title']['userPreferred'],\n            coverUrl: map['media']['coverImage'][imageQuality.value],\n            format: StringExtension.tryNoScreamingSnakeCase(map['media']['format']),\n            isAnime: map['type'] == 'ANIME_LIST',\n            siteUrl: map['siteUrl'],\n            text: '$status $progress',\n            createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),\n            isOwned: map['user']['id'] == viewerId,\n            replyCount: map['replyCount'] ?? 0,\n            likeCount: map['likeCount'] ?? 0,\n            isLiked: map['isLiked'] ?? false,\n            isSubscribed: map['isSubscribed'] ?? false,\n            isPinned: map['isPinned'] ?? false,\n          );\n        default:\n          return null;\n      }\n    } catch (_) {\n      return null;\n    }\n  }\n\n  final int id;\n  final int authorId;\n  final String authorName;\n  final String authorAvatarUrl;\n  final String text;\n  final String siteUrl;\n  final DateTime createdAt;\n  final bool isOwned;\n  int replyCount;\n  int likeCount;\n  bool isLiked;\n  bool isSubscribed;\n  bool isPinned;\n}\n\nclass StatusActivity extends Activity {\n  StatusActivity({\n    required super.id,\n    required super.authorId,\n    required super.authorName,\n    required super.authorAvatarUrl,\n    required super.createdAt,\n    required super.text,\n    required super.siteUrl,\n    required super.isOwned,\n    required super.replyCount,\n    required super.likeCount,\n    required super.isLiked,\n    required super.isSubscribed,\n    required super.isPinned,\n  });\n}\n\nclass MessageActivity extends Activity {\n  MessageActivity({\n    required super.id,\n    required super.authorId,\n    required super.authorName,\n    required super.authorAvatarUrl,\n    required super.createdAt,\n    required super.text,\n    required super.siteUrl,\n    required super.isOwned,\n    required super.replyCount,\n    required super.likeCount,\n    required super.isLiked,\n    required super.isSubscribed,\n    required super.isPinned,\n    required this.recipientId,\n    required this.recipientName,\n    required this.recipientAvatarUrl,\n    required this.isPrivate,\n  });\n\n  final int recipientId;\n  final String recipientName;\n  final String recipientAvatarUrl;\n  final bool isPrivate;\n}\n\nclass MediaActivity extends Activity {\n  MediaActivity({\n    required super.id,\n    required super.authorId,\n    required super.authorName,\n    required super.authorAvatarUrl,\n    required super.createdAt,\n    required super.text,\n    required super.siteUrl,\n    required super.isOwned,\n    required super.replyCount,\n    required super.likeCount,\n    required super.isLiked,\n    required super.isSubscribed,\n    required super.isPinned,\n    required this.mediaId,\n    required this.title,\n    required this.coverUrl,\n    required this.isAnime,\n    required this.format,\n  });\n\n  final int mediaId;\n  final String title;\n  final String coverUrl;\n  final bool isAnime;\n  final String? format;\n}\n\nclass ActivityReply {\n  ActivityReply._({\n    required this.id,\n    required this.authorId,\n    required this.authorName,\n    required this.authorAvatarUrl,\n    required this.text,\n    required this.createdAt,\n    this.likeCount = 0,\n    this.isLiked = false,\n  });\n\n  static ActivityReply? maybe(Map<String, dynamic> map) {\n    if (map['id'] == null || map['user']?['id'] == null) return null;\n\n    return ActivityReply._(\n      id: map['id'],\n      authorId: map['user']['id'],\n      authorName: map['user']['name'],\n      authorAvatarUrl: map['user']['avatar']['large'],\n      text: parseMarkdown(map['text'] ?? ''),\n      createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),\n      likeCount: map['likeCount'] ?? 0,\n      isLiked: map['isLiked'] ?? false,\n    );\n  }\n\n  final int id;\n  final int authorId;\n  final String authorName;\n  final String authorAvatarUrl;\n  final String text;\n  final DateTime createdAt;\n  int likeCount;\n  bool isLiked;\n}\n"
  },
  {
    "path": "lib/feature/activity/activity_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/feature/activity/activity_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\nimport 'package:otraku/util/paged.dart';\n\nfinal activityProvider = AsyncNotifierProvider.autoDispose\n    .family<ActivityNotifier, ExpandedActivity, int>(ActivityNotifier.new);\n\nclass ActivityNotifier extends AsyncNotifier<ExpandedActivity> {\n  ActivityNotifier(this.arg);\n\n  final int arg;\n\n  int? _viewerId;\n\n  @override\n  FutureOr<ExpandedActivity> build() async {\n    _viewerId = ref.watch(viewerIdProvider);\n    return await _fetch(null);\n  }\n\n  Future<void> fetch() async {\n    if (!(state.value?.replies.hasNext ?? true)) return;\n    state = await AsyncValue.guard(() => _fetch(state.value));\n  }\n\n  Future<ExpandedActivity> _fetch(ExpandedActivity? oldState) async {\n    final replies = oldState?.replies ?? const Paged();\n\n    final data = await ref.read(repositoryProvider).request(GqlQuery.activity, {\n      'id': arg,\n      'page': replies.next,\n      if (replies.next == 1) 'withActivity': true,\n    });\n\n    final items = <ActivityReply>[];\n    for (final r in data['Page']['activityReplies']) {\n      final item = ActivityReply.maybe(r);\n      if (item != null) items.add(item);\n    }\n\n    final activity =\n        oldState?.activity ??\n        Activity.maybe(\n          data['Activity'],\n          _viewerId,\n          ref.read(persistenceProvider).options.imageQuality,\n        );\n    if (activity == null) throw StateError('Could not parse activity');\n\n    return ExpandedActivity(\n      activity,\n      replies.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false),\n    );\n  }\n\n  void replace(Activity activity) {\n    final value = state.value;\n    if (value == null) return;\n\n    state = AsyncValue.data(ExpandedActivity(activity, value.replies));\n  }\n\n  void appendReply(Map<String, dynamic> map) {\n    final value = state.value;\n    if (value == null) return;\n\n    final reply = ActivityReply.maybe(map);\n    if (reply == null) return;\n\n    value.activity.replyCount++;\n    state = AsyncValue.data(\n      ExpandedActivity(\n        value.activity,\n        Paged(\n          items: [...value.replies.items, reply],\n          hasNext: value.replies.hasNext,\n          next: value.replies.next,\n        ),\n      ),\n    );\n  }\n\n  void replaceReply(Map<String, dynamic> map) {\n    final value = state.value;\n    if (value == null) return;\n\n    final reply = ActivityReply.maybe(map);\n    if (reply == null) return;\n\n    for (int i = 0; i < value.replies.items.length; i++) {\n      if (value.replies.items[i].id == reply.id) {\n        value.replies.items[i] = reply;\n        state = AsyncValue.data(\n          ExpandedActivity(\n            value.activity,\n            Paged(\n              items: value.replies.items,\n              hasNext: value.replies.hasNext,\n              next: value.replies.next,\n            ),\n          ),\n        );\n        return;\n      }\n    }\n  }\n\n  Future<Object?> toggleLike() {\n    return ref.read(repositoryProvider).request(GqlMutation.toggleLike, {\n      'id': arg,\n      'type': 'ACTIVITY',\n    }).getErrorOrNull();\n  }\n\n  Future<Object?> toggleSubscription() {\n    final isSubscribed = state.value?.activity.isSubscribed;\n    if (isSubscribed == null) return Future.value();\n\n    return ref.read(repositoryProvider).request(GqlMutation.toggleActivitySubscription, {\n      'id': arg,\n      'subscribe': isSubscribed,\n    }).getErrorOrNull();\n  }\n\n  Future<Object?> togglePin() {\n    final isPinned = state.value?.activity.isPinned;\n    if (isPinned == null) return Future.value();\n\n    return ref.read(repositoryProvider).request(GqlMutation.toggleActivityPin, {\n      'id': arg,\n      'pinned': isPinned,\n    }).getErrorOrNull();\n  }\n\n  Future<Object?> toggleReplyLike(int replyId) {\n    return ref.read(repositoryProvider).request(GqlMutation.toggleLike, {\n      'id': replyId,\n      'type': 'ACTIVITY_REPLY',\n    }).getErrorOrNull();\n  }\n\n  Future<Object?> remove() {\n    return ref.read(repositoryProvider).request(GqlMutation.deleteActivity, {\n      'id': arg,\n    }).getErrorOrNull();\n  }\n\n  Future<Object?> removeReply(int replyId) async {\n    final value = state.value;\n    if (value == null) return Future.value();\n\n    final err = await ref.read(repositoryProvider).request(GqlMutation.deleteActivityReply, {\n      'id': replyId,\n    }).getErrorOrNull();\n\n    if (err != null) return err;\n\n    for (int i = 0; i < value.replies.items.length; i++) {\n      if (value.replies.items[i].id == replyId) {\n        value.replies.items.removeAt(i);\n        value.activity.replyCount--;\n\n        state = AsyncValue.data(\n          ExpandedActivity(\n            value.activity,\n            Paged(\n              items: value.replies.items,\n              hasNext: value.replies.hasNext,\n              next: value.replies.next,\n            ),\n          ),\n        );\n        break;\n      }\n    }\n\n    return null;\n  }\n}\n"
  },
  {
    "path": "lib/feature/activity/activity_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/feature/activity/activities_provider.dart';\nimport 'package:otraku/feature/activity/activity_model.dart';\nimport 'package:otraku/feature/activity/activity_provider.dart';\nimport 'package:otraku/feature/activity/activity_card.dart';\nimport 'package:otraku/feature/activity/reply_card.dart';\nimport 'package:otraku/feature/composition/composition_model.dart';\nimport 'package:otraku/feature/composition/composition_view.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass ActivityView extends ConsumerStatefulWidget {\n  const ActivityView(this.id, this.sourceTag);\n\n  final int id;\n  final ActivitiesTag? sourceTag;\n\n  @override\n  ConsumerState<ActivityView> createState() => _ActivityViewState();\n}\n\nclass _ActivityViewState extends ConsumerState<ActivityView> {\n  late final _scrollCtrl = PagedController(\n    loadMore: () => ref.read(activityProvider(widget.id).notifier).fetch(),\n  );\n\n  @override\n  void dispose() {\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final activity = ref.watch(activityProvider(widget.id).select((s) => s.value?.activity));\n\n    return AdaptiveScaffold(\n      topBar: TopBar(trailing: [if (activity != null) _TopBarContent(activity)]),\n      floatingAction: HidingFloatingActionButton(\n        key: const Key('Reply'),\n        scrollCtrl: _scrollCtrl,\n        child: FloatingActionButton(\n          tooltip: 'New Reply',\n          child: const Icon(Icons.edit_outlined),\n          onPressed: () => showSheet(\n            context,\n            CompositionView(\n              tag: ActivityReplyCompositionTag(id: null, activityId: widget.id),\n              onSaved: (map) => ref.read(activityProvider(widget.id).notifier).appendReply(map),\n            ),\n          ),\n        ),\n      ),\n      child: _View(id: widget.id, sourceTag: widget.sourceTag, scrollCtrl: _scrollCtrl),\n    );\n  }\n}\n\nclass _TopBarContent extends StatelessWidget {\n  const _TopBarContent(this.activity);\n\n  final Activity activity;\n\n  @override\n  Widget build(BuildContext context) {\n    return Expanded(\n      child: Row(\n        children: [\n          Flexible(\n            child: GestureDetector(\n              behavior: .opaque,\n              onTap: () => context.push(Routes.user(activity.authorId, activity.authorAvatarUrl)),\n              child: Row(\n                mainAxisSize: .min,\n                children: [\n                  Hero(\n                    tag: activity.authorId,\n                    child: ClipRRect(\n                      borderRadius: Theming.borderRadiusSmall,\n                      child: CachedImage(activity.authorAvatarUrl, height: 40, width: 40),\n                    ),\n                  ),\n                  const SizedBox(width: Theming.offset),\n                  Flexible(child: Text(activity.authorName, overflow: .ellipsis, maxLines: 1)),\n                ],\n              ),\n            ),\n          ),\n          ...switch (activity) {\n            MessageActivity message => [\n              if (message.isPrivate)\n                const Padding(\n                  padding: .only(left: Theming.offset),\n                  child: Icon(Ionicons.eye_off_outline),\n                ),\n              const Padding(\n                padding: .symmetric(horizontal: Theming.offset),\n                child: Icon(Icons.arrow_right_alt),\n              ),\n              GestureDetector(\n                behavior: .opaque,\n                onTap: () =>\n                    context.push(Routes.user(message.recipientId, message.recipientAvatarUrl)),\n                child: ClipRRect(\n                  borderRadius: Theming.borderRadiusSmall,\n                  child: CachedImage(message.recipientAvatarUrl, height: 40, width: 40),\n                ),\n              ),\n            ],\n            _ when activity.isPinned => const [\n              Padding(\n                padding: .only(left: Theming.offset),\n                child: Icon(Icons.push_pin_outlined),\n              ),\n            ],\n            _ => const [],\n          },\n        ],\n      ),\n    );\n  }\n}\n\nclass _View extends ConsumerWidget {\n  const _View({required this.id, required this.sourceTag, required this.scrollCtrl});\n\n  final int id;\n  final ActivitiesTag? sourceTag;\n  final PagedController scrollCtrl;\n\n  @override\n  Widget build(BuildContext context, WidgetRef ref) {\n    ref.listen<AsyncValue>(\n      activityProvider(id),\n      (_, s) =>\n          s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())),\n    );\n\n    final viewerId = ref.watch(viewerIdProvider);\n\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    return ref\n        .watch(activityProvider(id))\n        .unwrapPrevious()\n        .when(\n          loading: () => const Center(child: Loader()),\n          error: (_, _) => const Center(child: Text('Failed to load activity')),\n          data: (data) {\n            return ConstrainedView(\n              child: CustomScrollView(\n                physics: Theming.bouncyPhysics,\n                controller: scrollCtrl,\n                slivers: [\n                  SliverRefreshControl(onRefresh: () => ref.invalidate(activityProvider(id))),\n                  SliverToBoxAdapter(\n                    child: ActivityCard(\n                      withHeader: false,\n                      analogClock: options.analogClock,\n                      highContrast: options.highContrast,\n                      activity: data.activity,\n                      footer: ActivityFooter(\n                        viewerId: viewerId,\n                        activity: data.activity,\n                        toggleLike: () => _toggleLike(ref, data.activity),\n                        toggleSubscription: () => _toggleSubscription(ref, data.activity),\n                        togglePin: () => _togglePin(ref, data.activity),\n                        remove: () => _remove(context, ref, data.activity),\n                        onEdited: (map) => _onEdited(ref, map),\n                        reply: () => _reply(context, ref, data.activity),\n                      ),\n                    ),\n                  ),\n                  SliverList(\n                    delegate: SliverChildBuilderDelegate(\n                      childCount: data.replies.items.length,\n                      (context, i) => ReplyCard(\n                        activityId: id,\n                        analogClock: options.analogClock,\n                        highContrast: options.highContrast,\n                        reply: data.replies.items[i],\n                        toggleLike: () => ref\n                            .read(activityProvider(id).notifier)\n                            .toggleReplyLike(data.replies.items[i].id),\n                      ),\n                    ),\n                  ),\n                  SliverFooter(loading: data.replies.hasNext),\n                ],\n              ),\n            );\n          },\n        );\n  }\n\n  Future<Object?> _toggleLike(WidgetRef ref, Activity activity) {\n    if (sourceTag != null) {\n      return ref.read(activitiesProvider(sourceTag!).notifier).toggleLike(activity);\n    }\n\n    return ref.read(activityProvider(id).notifier).toggleLike();\n  }\n\n  Future<Object?> _toggleSubscription(WidgetRef ref, Activity activity) {\n    if (sourceTag != null) {\n      return ref.read(activitiesProvider(sourceTag!).notifier).toggleSubscription(activity);\n    }\n\n    return ref.read(activityProvider(id).notifier).toggleSubscription();\n  }\n\n  Future<Object?> _togglePin(WidgetRef ref, Activity activity) {\n    if (sourceTag != null) {\n      return ref.read(activitiesProvider(sourceTag!).notifier).togglePin(activity);\n    }\n\n    return ref.read(activityProvider(id).notifier).togglePin();\n  }\n\n  Future<Object?> _remove(BuildContext context, WidgetRef ref, Activity activity) {\n    Navigator.pop(context);\n\n    if (sourceTag != null) {\n      return ref.read(activitiesProvider(sourceTag!).notifier).remove(activity);\n    }\n\n    return ref.read(activityProvider(id).notifier).remove();\n  }\n\n  void _onEdited(WidgetRef ref, Map<String, dynamic> map) {\n    final persistence = ref.read(persistenceProvider);\n\n    final activity = Activity.maybe(\n      map,\n      persistence.accountGroup.account?.id,\n      persistence.options.imageQuality,\n    );\n\n    if (activity == null) return;\n\n    ref.read(activityProvider(id).notifier).replace(activity);\n    if (sourceTag != null) {\n      ref.read(activitiesProvider(sourceTag!).notifier).replace(activity);\n    }\n  }\n\n  Future<void> _reply(BuildContext context, WidgetRef ref, Activity activity) {\n    return showSheet(\n      context,\n      CompositionView(\n        defaultText: '@${activity.authorName} ',\n        tag: ActivityReplyCompositionTag(id: null, activityId: id),\n        onSaved: (map) => ref.read(activityProvider(id).notifier).appendReply(map),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/activity/reply_card.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/activity/activity_model.dart';\nimport 'package:otraku/feature/activity/activity_provider.dart';\nimport 'package:otraku/feature/composition/composition_model.dart';\nimport 'package:otraku/feature/composition/composition_view.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/widget/timestamp.dart';\n\nclass ReplyCard extends StatelessWidget {\n  const ReplyCard({\n    required this.activityId,\n    required this.reply,\n    required this.analogClock,\n    required this.highContrast,\n    required this.toggleLike,\n  });\n\n  final int activityId;\n  final ActivityReply reply;\n  final bool analogClock;\n  final bool highContrast;\n  final Future<Object?> Function() toggleLike;\n\n  @override\n  Widget build(BuildContext context) {\n    const avatarSize = 50.0;\n\n    return Column(\n      mainAxisSize: .min,\n      crossAxisAlignment: .start,\n      spacing: 5,\n      children: [\n        GestureDetector(\n          behavior: .opaque,\n          onTap: () => context.push(Routes.user(reply.authorId, reply.authorAvatarUrl)),\n          child: Row(\n            mainAxisSize: .min,\n            spacing: Theming.offset,\n            children: [\n              ClipRRect(\n                borderRadius: Theming.borderRadiusSmall,\n                child: CachedImage(reply.authorAvatarUrl, height: avatarSize, width: avatarSize),\n              ),\n              Flexible(child: Text(reply.authorName, overflow: .ellipsis, maxLines: 1)),\n            ],\n          ),\n        ),\n        CardExtension.highContrast(highContrast)(\n          margin: const .only(bottom: Theming.offset),\n          child: Padding(\n            padding: const .only(top: Theming.offset, left: Theming.offset, right: Theming.offset),\n            child: Column(\n              mainAxisSize: .min,\n              children: [\n                UnconstrainedBox(\n                  constrainedAxis: Axis.horizontal,\n                  alignment: Alignment.topLeft,\n                  child: HtmlContent(reply.text),\n                ),\n                Row(\n                  mainAxisAlignment: .spaceBetween,\n                  spacing: 5,\n                  children: [\n                    Expanded(child: Timestamp(reply.createdAt, analogClock)),\n                    Consumer(\n                      builder: (context, ref, _) => SizedBox(\n                        height: 40,\n                        child: reply.authorId == ref.watch(viewerIdProvider)\n                            ? Tooltip(\n                                message: 'More',\n                                child: InkResponse(\n                                  radius: Theming.radiusSmall.x,\n                                  onTap: () => _showMoreSheet(context, ref),\n                                  child: const Icon(\n                                    Ionicons.ellipsis_horizontal,\n                                    size: Theming.iconSmall,\n                                  ),\n                                ),\n                              )\n                            : _ReplyMentionButton(ref, activityId, reply.authorName),\n                      ),\n                    ),\n                    _ReplyLikeButton(reply: reply, toggleLike: toggleLike),\n                  ],\n                ),\n              ],\n            ),\n          ),\n        ),\n      ],\n    );\n  }\n\n  /// Show a sheet with additional options.\n  void _showMoreSheet(BuildContext context, WidgetRef ref) {\n    showSheet(\n      context,\n      SimpleSheet.list([\n        ListTile(\n          title: const Text('Edit'),\n          leading: const Icon(Icons.edit_outlined),\n          onTap: () => showSheet(\n            context,\n            CompositionView(\n              tag: ActivityReplyCompositionTag(id: reply.id, activityId: activityId),\n              onSaved: (map) {\n                ref.read(activityProvider(activityId).notifier).replaceReply(map);\n                Navigator.pop(context);\n              },\n            ),\n          ),\n        ),\n        ListTile(\n          title: const Text('Delete'),\n          leading: const Icon(Ionicons.trash_outline),\n          onTap: () => ConfirmationDialog.show(\n            context,\n            title: 'Delete?',\n            primaryAction: 'Yes',\n            secondaryAction: 'No',\n            onConfirm: () async {\n              final err = await ref\n                  .read(activityProvider(activityId).notifier)\n                  .removeReply(reply.id);\n\n              if (err == null) {\n                if (context.mounted) Navigator.pop(context);\n                return;\n              }\n\n              if (context.mounted) {\n                SnackBarExtension.show(context, err.toString());\n                Navigator.pop(context);\n              }\n            },\n          ),\n        ),\n      ]),\n    );\n  }\n}\n\nclass _ReplyMentionButton extends StatelessWidget {\n  const _ReplyMentionButton(this.ref, this.activityId, this.username);\n\n  final WidgetRef ref;\n  final int activityId;\n  final String username;\n\n  @override\n  Widget build(BuildContext context) {\n    return SizedBox(\n      height: 40,\n      child: Tooltip(\n        message: 'Reply',\n        child: InkResponse(\n          radius: Theming.radiusSmall.x,\n          onTap: () => showSheet(\n            context,\n            CompositionView(\n              defaultText: '@$username ',\n              tag: ActivityReplyCompositionTag(id: null, activityId: activityId),\n              onSaved: (map) => ref.read(activityProvider(activityId).notifier).appendReply(map),\n            ),\n          ),\n          child: const Icon(Icons.reply_rounded, size: Theming.iconSmall),\n        ),\n      ),\n    );\n  }\n}\n\nclass _ReplyLikeButton extends StatefulWidget {\n  const _ReplyLikeButton({required this.reply, required this.toggleLike});\n\n  final ActivityReply reply;\n  final Future<Object?> Function() toggleLike;\n\n  @override\n  _ReplyLikeButtonState createState() => _ReplyLikeButtonState();\n}\n\nclass _ReplyLikeButtonState extends State<_ReplyLikeButton> {\n  @override\n  Widget build(BuildContext context) {\n    return SizedBox(\n      height: 40,\n      child: Tooltip(\n        message: !widget.reply.isLiked ? 'Like' : 'Unlike',\n        child: InkResponse(\n          radius: Theming.radiusSmall.x,\n          onTap: _toggleLike,\n          child: Row(\n            children: [\n              Text(\n                widget.reply.likeCount.toString(),\n                style: !widget.reply.isLiked\n                    ? TextTheme.of(context).labelSmall\n                    : TextTheme.of(\n                        context,\n                      ).labelSmall!.copyWith(color: ColorScheme.of(context).primary),\n              ),\n              const SizedBox(width: 5),\n              Icon(\n                !widget.reply.isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded,\n                size: Theming.iconSmall,\n                color: widget.reply.isLiked ? ColorScheme.of(context).primary : null,\n              ),\n            ],\n          ),\n        ),\n      ),\n    );\n  }\n\n  void _toggleLike() async {\n    final reply = widget.reply;\n    final isLiked = reply.isLiked;\n\n    setState(() {\n      reply.isLiked = !isLiked;\n      reply.likeCount += isLiked ? -1 : 1;\n    });\n\n    final err = await widget.toggleLike();\n    if (err == null) return;\n\n    setState(() {\n      reply.isLiked = isLiked;\n      reply.likeCount += isLiked ? 1 : -1;\n    });\n\n    if (mounted) SnackBarExtension.show(context, err.toString());\n  }\n}\n"
  },
  {
    "path": "lib/feature/calendar/calendar_filter_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/calendar/calendar_models.dart';\n\nfinal calendarFilterProvider = NotifierProvider.autoDispose<CalendarFilterNotifier, CalendarFilter>(\n  CalendarFilterNotifier.new,\n);\n\nclass CalendarFilterNotifier extends Notifier<CalendarFilter> {\n  @override\n  CalendarFilter build() => ref.watch(persistenceProvider.select((s) => s.calendarFilter));\n\n  @override\n  set state(CalendarFilter newState) {\n    ref.read(persistenceProvider.notifier).setCalendarFilter(newState);\n  }\n}\n"
  },
  {
    "path": "lib/feature/calendar/calendar_filter_sheet.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/feature/calendar/calendar_filter_provider.dart';\nimport 'package:otraku/feature/calendar/calendar_models.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\n\nvoid showCalendarFilterSheet(BuildContext context, WidgetRef ref) {\n  final highContrast = ref.read(persistenceProvider.select((s) => s.options.highContrast));\n  final filter = ref.read(calendarFilterProvider);\n  CalendarSeasonFilter season = filter.season;\n  CalendarStatusFilter status = filter.status;\n\n  showSheet(\n    context,\n    SimpleSheet(\n      initialHeight: Theming.normalTapTarget * 2 + MediaQuery.paddingOf(context).bottom + 40,\n      builder: (context, scrollCtrl) => ListView(\n        controller: scrollCtrl,\n        physics: Theming.bouncyPhysics,\n        padding: const .symmetric(horizontal: Theming.offset, vertical: 20),\n        children: [\n          ChipSelector(\n            title: 'Season',\n            items: CalendarSeasonFilter.values.skip(1).map((v) => (v.label, v)).toList(),\n            value: season != .all ? season : null,\n            onChanged: (v) => season = v ?? .all,\n            highContrast: highContrast,\n          ),\n          ChipSelector(\n            title: 'Status',\n            items: CalendarStatusFilter.values.skip(1).map((v) => (v.label, v)).toList(),\n            value: status != .all ? status : null,\n            onChanged: (v) => status = v ?? .all,\n            highContrast: highContrast,\n          ),\n        ],\n      ),\n    ),\n  ).then((_) {\n    if (season != filter.season || status != filter.status) {\n      ref.read(calendarFilterProvider.notifier).state = filter.copyWith(\n        season: season,\n        status: status,\n      );\n    }\n  });\n}\n"
  },
  {
    "path": "lib/feature/calendar/calendar_models.dart",
    "content": "import 'package:flutter/widgets.dart';\nimport 'package:otraku/extension/color_extension.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/extension/enum_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\n\nclass CalendarItem {\n  const CalendarItem._({\n    required this.mediaId,\n    required this.title,\n    required this.cover,\n    required this.episode,\n    required this.airingAt,\n    required this.entryStatus,\n    required this.streamingServices,\n  });\n\n  factory CalendarItem(Map<String, dynamic> map, ImageQuality imageQuality) {\n    final streamingServices = <StreamingService>[];\n    if (map['media']['externalLinks'] != null) {\n      for (final link in map['media']['externalLinks']) {\n        if (link['type'] == 'STREAMING') {\n          streamingServices.add((\n            url: link['url'],\n            site: link['site'],\n            color: link['color'] != null ? ColorExtension.fromHexString(link['color']) : null,\n          ));\n        }\n      }\n    }\n\n    return CalendarItem._(\n      mediaId: map['mediaId'],\n      title: map['media']['title']['userPreferred'],\n      cover: map['media']['coverImage'][imageQuality.value],\n      episode: map['episode'],\n      airingAt: DateTimeExtension.fromSecondsSinceEpoch(map['airingAt']),\n      entryStatus: ListStatus.from(map['media']['mediaListEntry']?['status']),\n      streamingServices: streamingServices,\n    );\n  }\n\n  final int mediaId;\n  final String title;\n  final String cover;\n  final int episode;\n  final DateTime airingAt;\n  final ListStatus? entryStatus;\n  final List<StreamingService> streamingServices;\n}\n\ntypedef StreamingService = ({String url, String site, Color? color});\n\nclass CalendarFilter {\n  const CalendarFilter({required this.date, required this.season, required this.status});\n\n  factory CalendarFilter.empty() =>\n      CalendarFilter(date: DateTime.now(), season: .all, status: .all);\n\n  factory CalendarFilter.fromPersistenceMap(Map<dynamic, dynamic> map) {\n    final season = CalendarSeasonFilter.values.getOrFirst(map['season']);\n    final status = CalendarStatusFilter.values.getOrFirst(map['status']);\n\n    return CalendarFilter(date: DateTime.now(), season: season, status: status);\n  }\n\n  final DateTime date;\n  final CalendarSeasonFilter season;\n  final CalendarStatusFilter status;\n\n  CalendarFilter copyWith({\n    DateTime? date,\n    CalendarSeasonFilter? season,\n    CalendarStatusFilter? status,\n  }) => CalendarFilter(\n    date: date ?? this.date,\n    season: season ?? this.season,\n    status: status ?? this.status,\n  );\n\n  Map<String, dynamic> toPersistenceMap() => {'season': season.index, 'status': status.index};\n}\n\nenum CalendarSeasonFilter {\n  all('All'),\n  current('Current'),\n  previous('Previous'),\n  other('Other');\n\n  const CalendarSeasonFilter(this.label);\n\n  final String label;\n}\n\nenum CalendarStatusFilter {\n  all('All'),\n  watchingAndPlanning('Watching And Planning'),\n  notInLists('Not In Lists'),\n  other('Other');\n\n  const CalendarStatusFilter(this.label);\n\n  final String label;\n}\n"
  },
  {
    "path": "lib/feature/calendar/calendar_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/util/graphql.dart';\nimport 'package:otraku/feature/calendar/calendar_filter_provider.dart';\nimport 'package:otraku/feature/calendar/calendar_models.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\n\nfinal calendarProvider = AsyncNotifierProvider.autoDispose<CalendarNotifier, Paged<CalendarItem>>(\n  CalendarNotifier.new,\n);\n\nclass CalendarNotifier extends AsyncNotifier<Paged<CalendarItem>> {\n  late CalendarFilter filter;\n\n  @override\n  FutureOr<Paged<CalendarItem>> build() async {\n    filter = ref.watch(calendarFilterProvider);\n    return await _fetch(const Paged());\n  }\n\n  Future<void> fetch(bool onAnime) async {\n    final oldState = state.value ?? const Paged();\n    if (!oldState.hasNext) return;\n    state = await AsyncValue.guard(() => _fetch(oldState));\n  }\n\n  Future<Paged<CalendarItem>> _fetch(Paged<CalendarItem> oldState) async {\n    final airingFrom = filter.date.copyWith(hour: 0, minute: 0, second: 0).secondsSinceEpoch;\n    final airingTo = filter.date.copyWith(hour: 23, minute: 59, second: 59).secondsSinceEpoch;\n\n    final data = await ref.read(repositoryProvider).request(GqlQuery.calendar, {\n      'page': oldState.next,\n      'airingFrom': airingFrom,\n      'airingTo': airingTo,\n    });\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n    final items = <CalendarItem>[];\n    for (final c in data['Page']['airingSchedules']) {\n      final season = c['media']['season'];\n      final year = c['media']['seasonYear'];\n      if (season == null || year == null) continue;\n\n      switch (filter.season) {\n        case .current:\n          final currSeason = _previousAndCurrentSeason().$2;\n          if (season != currSeason || year < filter.date.year - 1) continue;\n        case .previous:\n          final prevSeason = _previousAndCurrentSeason().$1;\n          if (season != prevSeason || year < filter.date.year - 1) continue;\n        case .other:\n          final (prevSeason, currSeason) = _previousAndCurrentSeason();\n          if ((season == prevSeason || season == currSeason) && year >= filter.date.year - 1) {\n            continue;\n          }\n          break;\n        case .all:\n          break;\n      }\n\n      final status = c['media']['mediaListEntry']?['status'];\n      switch (filter.status) {\n        case .notInLists:\n          if (status != null) continue;\n        case .watchingAndPlanning:\n          if (status != ListStatus.current.value && status != ListStatus.planning.value) {\n            continue;\n          }\n        case .other:\n          if (status == null ||\n              status == ListStatus.current.value ||\n              status == ListStatus.planning.value) {\n            continue;\n          }\n        case .all:\n          break;\n      }\n\n      items.add(CalendarItem(c, imageQuality));\n    }\n\n    return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false);\n  }\n\n  (String, String) _previousAndCurrentSeason() => switch (filter.date.month) {\n    >= 3 && <= 5 => ('WINTER', 'SPRING'),\n    >= 6 && <= 8 => ('SPRING', 'SUMMER'),\n    >= 9 && <= 11 => ('SUMMER', 'FALL'),\n    _ => ('FALL', 'WINTER'),\n  };\n}\n"
  },
  {
    "path": "lib/feature/calendar/calendar_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/navigation_tool.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/widget/text_rail.dart';\nimport 'package:otraku/feature/calendar/calendar_filter_provider.dart';\nimport 'package:otraku/feature/calendar/calendar_filter_sheet.dart';\nimport 'package:otraku/feature/calendar/calendar_models.dart';\nimport 'package:otraku/feature/calendar/calendar_provider.dart';\n\nclass CalendarView extends StatefulWidget {\n  const CalendarView();\n\n  @override\n  State<CalendarView> createState() => _CalendarViewState();\n}\n\nclass _CalendarViewState extends State<CalendarView> {\n  final _scrollCtrl = ScrollController();\n\n  @override\n  void dispose() {\n    super.dispose();\n    _scrollCtrl.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final textTheme = TextTheme.of(context);\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);\n    final tileHeight = bodyMediumLineHeight * 2 + labelMediumLineHeight + 55;\n    final coverWidth = tileHeight / Theming.coverHtoWRatio;\n\n    return Consumer(\n      builder: (context, ref, _) {\n        final options = ref.watch(persistenceProvider.select((s) => s.options));\n        final date = ref.watch(calendarFilterProvider.select((s) => s.date));\n        final today = DateTime.now();\n        final isBeforeToday =\n            date.day < today.day && date.month == today.month && date.year == today.year;\n\n        return AdaptiveScaffold(\n          topBar: const TopBar(title: 'Calendar'),\n          floatingAction: HidingFloatingActionButton(\n            key: const Key('filter'),\n            scrollCtrl: _scrollCtrl,\n            child: FloatingActionButton(\n              tooltip: 'Filter',\n              onPressed: () => showCalendarFilterSheet(context, ref),\n              child: const Icon(Ionicons.funnel_outline),\n            ),\n          ),\n          bottomBar: BottomBar([\n            const SizedBox(width: Theming.offset),\n            SizedBox(\n              width: 60,\n              child: isBeforeToday\n                  ? null\n                  : IconButton(\n                      icon: const Icon(Icons.arrow_back_ios_rounded),\n                      onPressed: () => _setDate(ref, date.subtract(const Duration(days: 1))),\n                    ),\n            ),\n            Expanded(\n              child: TextButton(\n                onPressed: () =>\n                    showDatePicker(\n                      context: context,\n                      initialDate: date,\n                      firstDate: today.add(const Duration(days: -1)),\n                      lastDate: today.add(const Duration(days: 150)),\n                    ).then((newDate) {\n                      if (newDate != null && newDate != date) {\n                        _setDate(ref, newDate);\n                      }\n                    }),\n                child: Text(date.formattedWithWeekDay),\n              ),\n            ),\n            SizedBox(\n              width: 60,\n              child: IconButton(\n                icon: const Icon(Icons.arrow_forward_ios_rounded),\n                onPressed: () => _setDate(ref, date.add(const Duration(days: 1))),\n              ),\n            ),\n            const SizedBox(width: Theming.offset),\n          ]),\n          child: PagedView(\n            provider: calendarProvider,\n            scrollCtrl: _scrollCtrl,\n            onRefresh: (invalidate) => invalidate(calendarProvider),\n            onData: (data) => SliverGrid(\n              delegate: SliverChildBuilderDelegate(\n                (context, i) =>\n                    _Tile(data.items[i], coverWidth, options.highContrast, options.analogClock),\n                childCount: data.items.length,\n              ),\n              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(\n                crossAxisCount: 1,\n                mainAxisExtent: tileHeight,\n                mainAxisSpacing: Theming.offset,\n                crossAxisSpacing: Theming.offset,\n              ),\n            ),\n          ),\n        );\n      },\n    );\n  }\n\n  void _setDate(WidgetRef ref, DateTime date) {\n    final filter = ref.read(calendarFilterProvider);\n    ref.read(calendarFilterProvider.notifier).state = filter.copyWith(date: date);\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile(this.item, this.coverWidth, this.highContrast, this.analogClock);\n\n  final CalendarItem item;\n  final double coverWidth;\n  final bool highContrast;\n  final bool analogClock;\n\n  @override\n  Widget build(BuildContext context) {\n    final textRailItems = {\n      item.airingAt.formattedTime(analogClock): true,\n      if (item.airingAt.isAfter(DateTime.now()))\n        'Ep ${item.episode} in ${item.airingAt.timeUntil}': false\n      else\n        'Ep ${item.episode}': false,\n    };\n\n    if (item.entryStatus != null) {\n      textRailItems[item.entryStatus!.label(true)] = true;\n    }\n\n    return CardExtension.highContrast(highContrast)(\n      child: MediaRouteTile(\n        id: item.mediaId,\n        imageUrl: item.cover,\n        child: Row(\n          children: [\n            Hero(\n              tag: item.mediaId,\n              child: ClipRRect(\n                borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n                child: Container(\n                  width: coverWidth,\n                  color: ColorScheme.of(context).surfaceContainerHighest,\n                  child: CachedImage(item.cover),\n                ),\n              ),\n            ),\n            Expanded(\n              child: Padding(\n                padding: const .symmetric(vertical: 5),\n                child: Column(\n                  crossAxisAlignment: .start,\n                  mainAxisAlignment: .spaceAround,\n                  children: [\n                    Flexible(\n                      child: Padding(\n                        padding: const .symmetric(horizontal: Theming.offset),\n                        child: Text(item.title, overflow: .ellipsis, maxLines: 2),\n                      ),\n                    ),\n                    Padding(\n                      padding: const .symmetric(horizontal: Theming.offset, vertical: 5),\n                      child: TextRail(\n                        textRailItems,\n                        style: TextTheme.of(context).labelMedium,\n                        maxLines: 1,\n                      ),\n                    ),\n                    if (item.streamingServices.isNotEmpty)\n                      SizedBox(height: 35, child: _ExternalLinkList(item.streamingServices)),\n                  ],\n                ),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n\nclass _ExternalLinkList extends StatelessWidget {\n  const _ExternalLinkList(this.links);\n\n  final List<StreamingService> links;\n\n  @override\n  Widget build(BuildContext context) {\n    return ListView.builder(\n      scrollDirection: Axis.horizontal,\n      padding: const .only(left: Theming.offset, right: Theming.offset / 2),\n      itemCount: links.length,\n      itemBuilder: (context, i) {\n        return Padding(\n          padding: const .only(right: Theming.offset / 2),\n          child: ActionChip(\n            onPressed: () => SnackBarExtension.launch(context, links[i].url),\n            label: Text(links[i].site),\n            avatar: links[i].color != null\n                ? Container(\n                    height: 15,\n                    width: 15,\n                    decoration: BoxDecoration(\n                      borderRadius: Theming.borderRadiusSmall,\n                      color: links[i].color,\n                    ),\n                  )\n                : null,\n          ),\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/character/character_anime_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/character/character_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/grid/dual_relation_grid.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/feature/character/character_provider.dart';\nimport 'package:otraku/widget/shadowed_overflow_list.dart';\n\nclass CharacterAnimeSubview extends StatelessWidget {\n  const CharacterAnimeSubview({\n    required this.id,\n    required this.scrollCtrl,\n    required this.highContrast,\n  });\n\n  final int id;\n  final ScrollController scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView<(CharacterRelatedItem, CharacterRelatedItem?)>(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(characterMediaProvider(id)),\n      provider: characterMediaProvider(\n        id,\n      ).select((s) => s.unwrapPrevious().whenData((data) => data.assembleAnimeWithVoiceActors())),\n      onData: (data) {\n        return SliverMainAxisGroup(\n          slivers: [\n            _LanguageSelected(id),\n            DualRelationGrid(\n              items: data.items,\n              onTapPrimary: (item) => context.push(Routes.media(item.tileId, item.tileImageUrl)),\n              onTapSecondary: (item) => context.push(Routes.staff(item.tileId, item.tileImageUrl)),\n              highContrast: highContrast,\n            ),\n          ],\n        );\n      },\n    );\n  }\n}\n\nclass _LanguageSelected extends StatelessWidget {\n  const _LanguageSelected(this.id);\n\n  final int id;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, child) {\n        final selection = ref.watch(\n          characterMediaProvider(id).select((s) {\n            final value = s.value;\n            if (value == null) return null;\n            return (value.languageToVoiceActors, value.selectedLanguage);\n          }),\n        );\n\n        if (selection == null) return const SliverToBoxAdapter();\n\n        final languageMappings = selection.$1;\n        final selectedLanguage = selection.$2;\n\n        if (languageMappings.length < 2) return const SliverToBoxAdapter();\n\n        return SliverToBoxAdapter(\n          child: SizedBox(\n            height: Theming.normalTapTarget,\n            child: ShadowedOverflowList(\n              itemCount: languageMappings.length,\n              itemBuilder: (context, i) => FilterChip(\n                label: Text(languageMappings[i].language),\n                selected: i == selectedLanguage,\n                onSelected: (selected) {\n                  if (!selected) return;\n\n                  ref.read(characterMediaProvider(id).notifier).changeLanguage(i);\n                },\n              ),\n            ),\n          ),\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/character/character_filter_model.dart",
    "content": "import 'package:otraku/feature/media/media_models.dart';\n\nclass CharacterFilter {\n  const CharacterFilter({this.sort = .trendingDesc, this.inLists});\n\n  final MediaSort sort;\n  final bool? inLists;\n\n  CharacterFilter copyWith({MediaSort? sort, (bool?,)? inLists}) => CharacterFilter(\n    sort: sort ?? this.sort,\n    inLists: inLists == null ? this.inLists : inLists.$1,\n  );\n}\n"
  },
  {
    "path": "lib/feature/character/character_filter_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/character/character_filter_model.dart';\n\nfinal characterFilterProvider = NotifierProvider.autoDispose\n    .family<CharacterFilterNotifier, CharacterFilter, int>(CharacterFilterNotifier.new);\n\nclass CharacterFilterNotifier extends Notifier<CharacterFilter> {\n  CharacterFilterNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  CharacterFilter build() => const CharacterFilter();\n\n  @override\n  set state(CharacterFilter newState) => super.state = newState;\n}\n"
  },
  {
    "path": "lib/feature/character/character_floating_actions.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/character/character_filter_provider.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass CharacterMediaFilterButton extends StatelessWidget {\n  const CharacterMediaFilterButton(this.id, this.ref);\n\n  final int id;\n  final WidgetRef ref;\n\n  @override\n  Widget build(BuildContext context) {\n    return FloatingActionButton(\n      tooltip: 'Filter',\n      child: const Icon(Ionicons.funnel_outline),\n      onPressed: () {\n        var filter = ref.read(characterFilterProvider(id));\n        final onDone = (_) => ref.read(characterFilterProvider(id).notifier).state = filter;\n        final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast));\n\n        showSheet(\n          context,\n          SimpleSheet(\n            initialHeight:\n                Theming.normalTapTarget * 2.5 + MediaQuery.paddingOf(context).bottom + 40,\n            builder: (context, scrollCtrl) => ListView(\n              controller: scrollCtrl,\n              physics: Theming.bouncyPhysics,\n              padding: const .symmetric(horizontal: Theming.offset, vertical: 20),\n              children: [\n                ChipSelector.ensureSelected(\n                  title: 'Sort',\n                  items: MediaSort.values.map((v) => (v.label, v)).toList(),\n                  value: filter.sort,\n                  onChanged: (v) => filter = filter.copyWith(sort: v),\n                  highContrast: highContrast,\n                ),\n                ChipSelector(\n                  title: 'List Presence',\n                  items: const [('In Lists', true), ('Not in Lists', false)],\n                  value: filter.inLists,\n                  onChanged: (v) => filter = filter.copyWith(inLists: (v,)),\n                  highContrast: highContrast,\n                ),\n              ],\n            ),\n          ),\n        ).then(onDone);\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/character/character_header.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/character/character_model.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/content_header.dart';\nimport 'package:otraku/widget/table_list.dart';\n\nclass CharacterHeader extends StatelessWidget {\n  const CharacterHeader.withTabBar({\n    required this.id,\n    required this.imageUrl,\n    required this.character,\n    required TabController this.tabCtrl,\n    required void Function() this.scrollToTop,\n    required this.toggleFavorite,\n    required this.highContrast,\n  });\n\n  const CharacterHeader.withoutTabBar({\n    required this.id,\n    required this.imageUrl,\n    required this.character,\n    required this.toggleFavorite,\n    required this.highContrast,\n  }) : tabCtrl = null,\n       scrollToTop = null;\n\n  final int id;\n  final String? imageUrl;\n  final Character? character;\n  final TabController? tabCtrl;\n  final void Function()? scrollToTop;\n  final Future<Object?> Function() toggleFavorite;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return ContentHeader(\n      imageUrl: imageUrl ?? character?.imageUrl,\n      imageHeightToWidthRatio: Theming.coverHtoWRatio,\n      imageHeroTag: id,\n      siteUrl: character?.siteUrl,\n      title: character?.preferredName,\n      details: character != null\n          ? [\n              TableList([\n                ('Favorites', character!.favorites.toString()),\n                if (character!.gender != null) ('Gender', character!.gender!),\n              ], highContrast: highContrast),\n            ]\n          : const [],\n      tabBarConfig: tabCtrl != null && scrollToTop != null\n          ? (tabCtrl: tabCtrl!, scrollToTop: scrollToTop!, tabs: tabsWithOverview)\n          : null,\n      trailingTopButtons: [if (character != null) _FavoriteButton(character!, toggleFavorite)],\n    );\n  }\n\n  static const tabsWithoutOverview = [Tab(text: 'Anime'), Tab(text: 'Manga')];\n\n  static const tabsWithOverview = [Tab(text: 'Overview'), ...tabsWithoutOverview];\n}\n\nclass _FavoriteButton extends StatefulWidget {\n  const _FavoriteButton(this.character, this.toggleFavorite);\n\n  final Character character;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  State<_FavoriteButton> createState() => __FavoriteButtonState();\n}\n\nclass __FavoriteButtonState extends State<_FavoriteButton> {\n  @override\n  Widget build(BuildContext context) {\n    final character = widget.character;\n\n    return IconButton(\n      tooltip: character.isFavorite ? 'Unfavourite' : 'Favourite',\n      icon: character.isFavorite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border),\n      onPressed: () async {\n        setState(() => character.isFavorite = !character.isFavorite);\n\n        final err = await widget.toggleFavorite();\n        if (err == null) return;\n\n        setState(() => character.isFavorite = !character.isFavorite);\n        if (context.mounted) SnackBarExtension.show(context, err.toString());\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/character/character_item_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/character/character_item_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\n\nclass CharacterItemGrid extends StatelessWidget {\n  const CharacterItemGrid(this.items, {required this.highContrast});\n\n  final List<CharacterItem> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final textHeight = lineHeight * 2 + 10;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight(\n        minWidth: 100,\n        extraHeight: textHeight,\n        rawHWRatio: Theming.coverHtoWRatio,\n      ),\n      delegate: SliverChildBuilderDelegate(\n        (_, i) => _Tile(items[i], highContrast, textHeight),\n        childCount: items.length,\n      ),\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile(this.item, this.highContrast, this.textHeight);\n\n  final CharacterItem item;\n  final bool highContrast;\n  final double textHeight;\n\n  @override\n  Widget build(BuildContext context) {\n    return InkWell(\n      borderRadius: Theming.borderRadiusSmall,\n      onTap: () => context.push(Routes.character(item.id, item.imageUrl)),\n      child: CardExtension.highContrast(highContrast)(\n        child: Column(\n          crossAxisAlignment: .stretch,\n          children: [\n            Expanded(\n              child: Hero(\n                tag: item.id,\n                child: ClipRRect(\n                  borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall),\n                  child: CachedImage(item.imageUrl),\n                ),\n              ),\n            ),\n            SizedBox(\n              height: textHeight,\n              child: Padding(\n                padding: const .all(5),\n                child: Text(item.name, maxLines: 2, overflow: .ellipsis),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/character/character_item_model.dart",
    "content": "class CharacterItem {\n  const CharacterItem._({required this.id, required this.name, required this.imageUrl});\n\n  factory CharacterItem(Map<String, dynamic> map) => CharacterItem._(\n    id: map['id'],\n    name: map['name']['userPreferred'],\n    imageUrl: map['image']['large'],\n  );\n\n  final int id;\n  final String name;\n  final String imageUrl;\n}\n"
  },
  {
    "path": "lib/feature/character/character_manga_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/character/character_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/widget/grid/mono_relation_grid.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/feature/character/character_provider.dart';\n\nclass CharacterMangaSubview extends StatelessWidget {\n  const CharacterMangaSubview({\n    required this.id,\n    required this.scrollCtrl,\n    required this.highContrast,\n  });\n\n  final int id;\n  final ScrollController scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView<CharacterRelatedItem>(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(characterMediaProvider(id)),\n      provider: characterMediaProvider(\n        id,\n      ).select((s) => s.unwrapPrevious().whenData((data) => data.manga)),\n      onData: (data) => MonoRelationGrid(\n        items: data.items,\n        onTap: (item) => context.push(Routes.media(item.tileId, item.tileImageUrl)),\n        highContrast: highContrast,\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/character/character_model.dart",
    "content": "import 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/util/markdown.dart';\nimport 'package:otraku/feature/settings/settings_model.dart';\nimport 'package:otraku/util/tile_modelable.dart';\n\nclass Character {\n  Character._({\n    required this.id,\n    required this.preferredName,\n    required this.fullName,\n    required this.nativeName,\n    required this.altNames,\n    required this.altNamesSpoilers,\n    required this.imageUrl,\n    required this.description,\n    required this.dateOfBirth,\n    required this.bloodType,\n    required this.gender,\n    required this.age,\n    required this.siteUrl,\n    required this.favorites,\n    required this.isFavorite,\n  });\n\n  factory Character(Map<String, dynamic> map, PersonNaming personNaming) {\n    final names = map['name'];\n    final nameSegments = [\n      names['first'],\n      if (names['middle']?.isNotEmpty ?? false) names['middle'],\n      if (names['last']?.isNotEmpty ?? false) names['last'],\n    ];\n\n    final fullName = personNaming == .romajiWestern\n        ? nameSegments.join(' ')\n        : nameSegments.reversed.toList().join(' ');\n    final nativeName = names['native'];\n\n    final altNames = List<String>.from(names['alternative'] ?? []);\n    final altNamesSpoilers = List<String>.from(names['alternativeSpoiler'] ?? [], growable: false);\n\n    final preferredName = nativeName != null\n        ? personNaming != .native\n              ? fullName\n              : nativeName\n        : fullName;\n\n    return Character._(\n      id: map['id'],\n      preferredName: preferredName,\n      fullName: fullName,\n      nativeName: nativeName,\n      altNames: altNames,\n      altNamesSpoilers: altNamesSpoilers,\n      description: parseMarkdown(map['description'] ?? ''),\n      imageUrl: map['image']['large'],\n      dateOfBirth: StringExtension.fromFuzzyDate(map['dateOfBirth']),\n      bloodType: map['bloodType'],\n      gender: map['gender'],\n      age: map['age'],\n      siteUrl: map['siteUrl'],\n      favorites: map['favourites'] ?? 0,\n      isFavorite: map['isFavourite'] ?? false,\n    );\n  }\n\n  final int id;\n  final String preferredName;\n  final String fullName;\n  final String? nativeName;\n  final List<String> altNames;\n  final List<String> altNamesSpoilers;\n  final String imageUrl;\n  final String description;\n  final String? dateOfBirth;\n  final String? bloodType;\n  final String? gender;\n  final String? age;\n  final String? siteUrl;\n  final int favorites;\n  bool isFavorite;\n}\n\nclass CharacterMedia {\n  const CharacterMedia({\n    this.anime = const Paged(),\n    this.manga = const Paged(),\n    this.languageToVoiceActors = const [],\n    this.selectedLanguage = 0,\n  });\n\n  final Paged<CharacterRelatedItem> anime;\n  final Paged<CharacterRelatedItem> manga;\n\n  /// For each language, a list of voice actors\n  /// is mapped to the corresponding media's id.\n  final List<CharacterLanguageMapping> languageToVoiceActors;\n  final int selectedLanguage;\n\n  /// Returns the media, in which the character has participated,\n  /// along with the voice actors, corresponding to the current [language].\n  /// If there are multiple actors, the given media is repeated for each actor.\n  Paged<(CharacterRelatedItem, CharacterRelatedItem?)> assembleAnimeWithVoiceActors() {\n    if (languageToVoiceActors.isEmpty) {\n      return Paged(\n        items: anime.items.map((a) => (a, null)).toList(),\n        hasNext: anime.hasNext,\n        next: anime.next,\n      );\n    }\n\n    final actorsPerMedia = languageToVoiceActors[selectedLanguage];\n\n    final animeAndVoiceActors = <(CharacterRelatedItem, CharacterRelatedItem?)>[];\n    for (final a in anime.items) {\n      final actors = actorsPerMedia.voiceActors[a.id];\n      if (actors == null || actors.isEmpty) {\n        animeAndVoiceActors.add((a, null));\n        continue;\n      }\n\n      for (final va in actors) {\n        animeAndVoiceActors.add((a, va));\n      }\n    }\n\n    return Paged(items: animeAndVoiceActors, hasNext: anime.hasNext, next: anime.next);\n  }\n}\n\nclass CharacterRelatedItem implements TileModelable {\n  const CharacterRelatedItem._({\n    required this.id,\n    required this.name,\n    required this.imageUrl,\n    required this.role,\n  });\n\n  factory CharacterRelatedItem.media(\n    Map<String, dynamic> map,\n    String? role,\n    ImageQuality imageQuality,\n  ) => CharacterRelatedItem._(\n    id: map['id'],\n    name: map['title']['userPreferred'],\n    imageUrl: map['coverImage'][imageQuality.value],\n    role: role,\n  );\n\n  factory CharacterRelatedItem.staff(Map<String, dynamic> map, String? role) =>\n      CharacterRelatedItem._(\n        id: map['id'],\n        name: map['name']['userPreferred'],\n        imageUrl: map['image']['large'],\n        role: role,\n      );\n\n  final int id;\n  final String name;\n  final String imageUrl;\n  final String? role;\n\n  @override\n  int get tileId => id;\n\n  @override\n  String get tileTitle => name;\n\n  @override\n  String? get tileSubtitle => role;\n\n  @override\n  String get tileImageUrl => imageUrl;\n}\n\ntypedef CharacterLanguageMapping = ({\n  String language,\n  Map<int, List<CharacterRelatedItem>> voiceActors,\n});\n"
  },
  {
    "path": "lib/feature/character/character_overview_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/character/character_model.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/table_list.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:otraku/widget/loaders.dart';\n\nclass CharacterOverviewSubview extends StatelessWidget {\n  const CharacterOverviewSubview.asFragment({\n    required this.character,\n    required this.invalidate,\n    required this.highContrast,\n    required ScrollController this.scrollCtrl,\n  }) : header = null;\n\n  const CharacterOverviewSubview.withHeader({\n    required this.character,\n    required this.invalidate,\n    required this.highContrast,\n    required Widget this.header,\n  }) : scrollCtrl = null;\n\n  final Character character;\n  final void Function() invalidate;\n  final Widget? header;\n  final ScrollController? scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final mediaQuery = MediaQuery.of(context);\n    final refreshControl = SliverRefreshControl(onRefresh: invalidate);\n\n    return CustomScrollView(\n      physics: Theming.bouncyPhysics,\n      controller: scrollCtrl,\n      slivers: [\n        if (header != null) ...[\n          header!,\n          MediaQuery(\n            data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)),\n            child: refreshControl,\n          ),\n        ] else\n          refreshControl,\n        SliverPadding(\n          padding: const .symmetric(horizontal: Theming.offset),\n          sliver: SliverMainAxisGroup(\n            slivers: [\n              _NameTable(character, highContrast),\n              const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)),\n              SliverTableList([\n                if (character.dateOfBirth != null) ('Birth', character.dateOfBirth!),\n                if (character.age != null) ('Age', character.age!),\n                if (character.bloodType != null) ('Blood Type', character.bloodType!),\n              ], highContrast: highContrast),\n              if (character.description.isNotEmpty) ...[\n                const SliverToBoxAdapter(child: SizedBox(height: 15)),\n                HtmlContent(character.description, renderMode: RenderMode.sliverList),\n              ],\n            ],\n          ),\n        ),\n        const SliverFooter(),\n      ],\n    );\n  }\n}\n\nclass _NameTable extends StatefulWidget {\n  const _NameTable(this.character, this.highContrast);\n\n  final Character character;\n  final bool highContrast;\n\n  @override\n  State<_NameTable> createState() => __NameTableState();\n}\n\nclass __NameTableState extends State<_NameTable> {\n  var _showSpoilers = false;\n\n  @override\n  Widget build(BuildContext context) {\n    return SliverMainAxisGroup(\n      slivers: [\n        SliverTableList([\n          ('Full', widget.character.fullName),\n          if (widget.character.nativeName != null) ('Native', widget.character.nativeName!),\n          ...widget.character.altNames.map((s) => ('Alternative', s)),\n          if (_showSpoilers)\n            ...widget.character.altNamesSpoilers.map((s) => ('Alternative Spoiler', s)),\n        ], highContrast: widget.highContrast),\n        if (widget.character.altNamesSpoilers.isNotEmpty && !_showSpoilers)\n          SliverToBoxAdapter(\n            child: TextButton.icon(\n              label: const Text('Show Spoilers'),\n              icon: const Icon(Ionicons.eye_outline),\n              onPressed: () => setState(() => _showSpoilers = true),\n            ),\n          ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/character/character_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/extension/iterable_extension.dart';\nimport 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/feature/character/character_filter_model.dart';\nimport 'package:otraku/feature/character/character_filter_provider.dart';\nimport 'package:otraku/feature/character/character_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\nimport 'package:otraku/feature/settings/settings_provider.dart';\n\nfinal characterProvider = AsyncNotifierProvider.autoDispose\n    .family<CharacterNotifier, Character, int>(CharacterNotifier.new);\n\nfinal characterMediaProvider = AsyncNotifierProvider.autoDispose\n    .family<CharacterMediaNotifier, CharacterMedia, int>(CharacterMediaNotifier.new);\n\nclass CharacterNotifier extends AsyncNotifier<Character> {\n  CharacterNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Character> build() async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.character, {\n      'id': arg,\n      'withInfo': true,\n    });\n\n    final personNaming = await ref.watch(settingsProvider.selectAsync((data) => data.personNaming));\n\n    return Character(data['Character'], personNaming);\n  }\n\n  Future<Object?> toggleFavorite() {\n    return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, {\n      'character': arg,\n    }).getErrorOrNull();\n  }\n}\n\nclass CharacterMediaNotifier extends AsyncNotifier<CharacterMedia> {\n  CharacterMediaNotifier(this.arg);\n\n  final int arg;\n\n  late CharacterFilter filter;\n\n  @override\n  FutureOr<CharacterMedia> build() async {\n    filter = ref.watch(characterFilterProvider(arg));\n    return await _fetch(const CharacterMedia(), null);\n  }\n\n  Future<void> fetch(bool onAnime) async {\n    final oldState = state.value ?? const CharacterMedia();\n    if (onAnime) {\n      if (!oldState.anime.hasNext) return;\n    } else {\n      if (!oldState.manga.hasNext) return;\n    }\n    state = await AsyncValue.guard(() => _fetch(oldState, onAnime));\n  }\n\n  Future<CharacterMedia> _fetch(CharacterMedia oldState, bool? onAnime) async {\n    final variables = {'id': arg, 'onList': filter.inLists, 'sort': filter.sort.value};\n\n    if (onAnime == null) {\n      variables['withAnime'] = true;\n      variables['withManga'] = true;\n    } else if (onAnime) {\n      variables['withAnime'] = true;\n      variables['page'] = oldState.anime.next;\n    } else if (!onAnime) {\n      variables['withManga'] = true;\n      variables['page'] = oldState.manga.next;\n    }\n\n    var data = await ref.read(repositoryProvider).request(GqlQuery.character, variables);\n    data = data['Character'];\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    var anime = oldState.anime;\n    var manga = oldState.manga;\n    var languageToVoiceActors = [...oldState.languageToVoiceActors];\n    var selectedLanguage = oldState.selectedLanguage;\n\n    if (onAnime == null || onAnime) {\n      final map = data['anime'];\n      final items = <CharacterRelatedItem>[];\n      for (final a in map['edges']) {\n        items.add(\n          CharacterRelatedItem.media(\n            a['node'],\n            StringExtension.tryNoScreamingSnakeCase(a['characterRole']),\n            imageQuality,\n          ),\n        );\n\n        if (a['voiceActors'] != null) {\n          for (final va in a['voiceActors']) {\n            final l = StringExtension.tryNoScreamingSnakeCase(va['languageV2']);\n            if (l == null) continue;\n\n            var languageMapping = languageToVoiceActors.firstWhereOrNull((lm) => lm.language == l);\n\n            if (languageMapping == null) {\n              languageMapping = (language: l, voiceActors: {});\n              languageToVoiceActors.add(languageMapping);\n            }\n\n            final mediaVoiceActors = languageMapping.voiceActors.putIfAbsent(\n              items.last.id,\n              () => [],\n            );\n\n            mediaVoiceActors.add(CharacterRelatedItem.staff(va, l));\n          }\n        }\n\n        languageToVoiceActors.sort((a, b) {\n          if (a.language == 'Japanese') return -1;\n          if (b.language == 'Japanese') return 1;\n          return a.language.compareTo(b.language);\n        });\n      }\n\n      anime = anime.withNext(items, map['pageInfo']['hasNextPage'] ?? false);\n    }\n\n    if (onAnime == null || !onAnime) {\n      final map = data['manga'];\n      final items = <CharacterRelatedItem>[];\n      for (final m in map['edges']) {\n        items.add(\n          CharacterRelatedItem.media(\n            m['node'],\n            StringExtension.tryNoScreamingSnakeCase(m['characterRole']),\n            imageQuality,\n          ),\n        );\n      }\n\n      manga = manga.withNext(items, map['pageInfo']['hasNextPage'] ?? false);\n    }\n\n    return CharacterMedia(\n      anime: anime,\n      manga: manga,\n      languageToVoiceActors: languageToVoiceActors,\n      selectedLanguage: selectedLanguage,\n    );\n  }\n\n  void changeLanguage(int selectedLanguage) => state.whenData((data) {\n    if (selectedLanguage >= data.languageToVoiceActors.length) return;\n\n    state = AsyncValue.data(\n      CharacterMedia(\n        anime: data.anime,\n        manga: data.manga,\n        languageToVoiceActors: data.languageToVoiceActors,\n        selectedLanguage: selectedLanguage,\n      ),\n    );\n  });\n}\n"
  },
  {
    "path": "lib/feature/character/character_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/scroll_controller_extension.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/character/character_header.dart';\nimport 'package:otraku/feature/character/character_model.dart';\nimport 'package:otraku/feature/character/character_floating_actions.dart';\nimport 'package:otraku/feature/character/character_anime_view.dart';\nimport 'package:otraku/feature/character/character_manga_view.dart';\nimport 'package:otraku/feature/character/character_provider.dart';\nimport 'package:otraku/feature/character/character_overview_view.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/dual_pane_with_tab_bar.dart';\nimport 'package:otraku/widget/loaders.dart';\n\nclass CharacterView extends ConsumerStatefulWidget {\n  const CharacterView(this.id, this.imageUrl);\n\n  final int id;\n  final String? imageUrl;\n\n  @override\n  ConsumerState<CharacterView> createState() => _CharacterViewState();\n}\n\nclass _CharacterViewState extends ConsumerState<CharacterView> {\n  final _scrollCtrl = PagedController(loadMore: () {});\n\n  @override\n  void dispose() {\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    ref.listen<AsyncValue>(characterProvider(widget.id), (_, s) {\n      if (s.hasError) {\n        SnackBarExtension.show(context, 'Failed to load character: ${s.error}');\n      }\n    });\n\n    final character = ref.watch(characterProvider(widget.id));\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    final toggleFavorite = () => ref.read(characterProvider(widget.id).notifier).toggleFavorite();\n\n    return AdaptiveScaffold(\n      floatingAction: HidingFloatingActionButton(\n        key: const Key('filter'),\n        scrollCtrl: _scrollCtrl,\n        child: CharacterMediaFilterButton(widget.id, ref),\n      ),\n      child: switch (Theming.of(context).formFactor) {\n        .phone => _CompactView(\n          id: widget.id,\n          imageUrl: widget.imageUrl,\n          ref: ref,\n          highContrast: options.highContrast,\n          character: character,\n          scrollCtrl: _scrollCtrl,\n          toggleFavorite: toggleFavorite,\n        ),\n        .tablet => _LargeView(\n          id: widget.id,\n          imageUrl: widget.imageUrl,\n          ref: ref,\n          highContrast: options.highContrast,\n          character: character,\n          scrollCtrl: _scrollCtrl,\n          toggleFavorite: toggleFavorite,\n        ),\n      },\n    );\n  }\n}\n\nclass _CompactView extends StatefulWidget {\n  const _CompactView({\n    required this.id,\n    required this.imageUrl,\n    required this.ref,\n    required this.highContrast,\n    required this.character,\n    required this.scrollCtrl,\n    required this.toggleFavorite,\n  });\n\n  final int id;\n  final String? imageUrl;\n  final WidgetRef ref;\n  final bool highContrast;\n  final AsyncValue<Character> character;\n  final PagedController scrollCtrl;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  State<_CompactView> createState() => _CompactViewState();\n}\n\nclass _CompactViewState extends State<_CompactView> with SingleTickerProviderStateMixin {\n  late final _tabCtrl = TabController(length: CharacterHeader.tabsWithOverview.length, vsync: this);\n\n  @override\n  void initState() {\n    super.initState();\n    widget.scrollCtrl.loadMore = () {\n      if (_tabCtrl.index > 0) {\n        widget.ref.read(characterMediaProvider(widget.id).notifier).fetch(_tabCtrl.index == 1);\n      }\n    };\n  }\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final mediaQuery = MediaQuery.of(context);\n\n    final header = CharacterHeader.withTabBar(\n      id: widget.id,\n      imageUrl: widget.imageUrl,\n      character: widget.character.value,\n      tabCtrl: _tabCtrl,\n      scrollToTop: widget.scrollCtrl.scrollToTop,\n      toggleFavorite: widget.toggleFavorite,\n      highContrast: widget.highContrast,\n    );\n\n    return NestedScrollView(\n      controller: widget.scrollCtrl,\n      headerSliverBuilder: (context, _) => [header],\n      body: MediaQuery(\n        data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)),\n        child: widget.character.unwrapPrevious().when(\n          loading: () => const Center(child: Loader()),\n          error: (_, _) => const Center(child: Text('Failed to load character')),\n          data: (data) => _CharacterTabs.withOverview(\n            id: widget.id,\n            character: data,\n            tabCtrl: _tabCtrl,\n            highContrast: widget.highContrast,\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass _LargeView extends StatefulWidget {\n  const _LargeView({\n    required this.id,\n    required this.imageUrl,\n    required this.ref,\n    required this.highContrast,\n    required this.character,\n    required this.scrollCtrl,\n    required this.toggleFavorite,\n  });\n\n  final int id;\n  final String? imageUrl;\n  final WidgetRef ref;\n  final bool highContrast;\n  final AsyncValue<Character> character;\n  final PagedController scrollCtrl;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  State<_LargeView> createState() => _LargeViewState();\n}\n\nclass _LargeViewState extends State<_LargeView> with SingleTickerProviderStateMixin {\n  late final _tabCtrl = TabController(\n    length: CharacterHeader.tabsWithoutOverview.length,\n    vsync: this,\n  );\n\n  @override\n  void initState() {\n    super.initState();\n    widget.scrollCtrl.loadMore = () {\n      widget.ref.read(characterMediaProvider(widget.id).notifier).fetch(_tabCtrl.index == 0);\n    };\n  }\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final header = CharacterHeader.withoutTabBar(\n      id: widget.id,\n      imageUrl: widget.imageUrl,\n      character: widget.character.value,\n      toggleFavorite: widget.toggleFavorite,\n      highContrast: widget.highContrast,\n    );\n\n    return DualPaneWithTabBar(\n      tabCtrl: _tabCtrl,\n      scrollToTop: widget.scrollCtrl.scrollToTop,\n      tabs: CharacterHeader.tabsWithoutOverview,\n      leftPane: widget.character.unwrapPrevious().when(\n        loading: () => CustomScrollView(\n          physics: Theming.bouncyPhysics,\n          slivers: [\n            header,\n            const SliverFillRemaining(child: Center(child: Loader())),\n          ],\n        ),\n        error: (_, _) => CustomScrollView(\n          physics: Theming.bouncyPhysics,\n          slivers: [\n            header,\n            const SliverFillRemaining(child: Center(child: Text('Failed to load character'))),\n          ],\n        ),\n        data: (data) => CharacterOverviewSubview.withHeader(\n          character: data,\n          header: header,\n          highContrast: widget.highContrast,\n          invalidate: () => widget.ref.invalidate(characterProvider(widget.id)),\n        ),\n      ),\n      rightPane: widget.character.unwrapPrevious().maybeWhen(\n        data: (data) => _CharacterTabs.withoutOverview(\n          id: widget.id,\n          character: data,\n          tabCtrl: _tabCtrl,\n          scrollCtrl: widget.scrollCtrl,\n          highContrast: widget.highContrast,\n        ),\n        orElse: () => const SizedBox(),\n      ),\n    );\n  }\n}\n\nclass _CharacterTabs extends ConsumerStatefulWidget {\n  const _CharacterTabs.withOverview({\n    required this.id,\n    required this.character,\n    required this.tabCtrl,\n    required this.highContrast,\n  }) : withOverview = true,\n       scrollCtrl = null;\n\n  const _CharacterTabs.withoutOverview({\n    required this.id,\n    required this.character,\n    required this.tabCtrl,\n    required this.highContrast,\n    required ScrollController this.scrollCtrl,\n  }) : withOverview = false;\n\n  final int id;\n  final Character character;\n  final TabController tabCtrl;\n  final ScrollController? scrollCtrl;\n  final bool highContrast;\n  final bool withOverview;\n\n  @override\n  ConsumerState<_CharacterTabs> createState() => __CharacterViewContentState();\n}\n\nclass __CharacterViewContentState extends ConsumerState<_CharacterTabs> {\n  late final ScrollController _scrollCtrl;\n  double _lastMaxExtent = 0;\n\n  @override\n  void initState() {\n    super.initState();\n    _scrollCtrl =\n        widget.scrollCtrl ??\n        context.findAncestorStateOfType<NestedScrollViewState>()!.innerController;\n\n    _scrollCtrl.addListener(_scrollListener);\n    widget.tabCtrl.addListener(_tabListener);\n  }\n\n  @override\n  void dispose() {\n    _scrollCtrl.removeListener(_scrollListener);\n    widget.tabCtrl.removeListener(_tabListener);\n    super.dispose();\n  }\n\n  void _tabListener() {\n    _lastMaxExtent = 0;\n\n    // This is a workaround for an issue with [NestedScrollView].\n    // If you switch to a tab with pagination, where the content\n    // doesn't fill the view, the scroll controller has it's maximum\n    // extent set to 0 and the loading of a next page of items is not triggered.\n    // This is why we need to manually load the second page.\n    if (!widget.tabCtrl.indexIsChanging && _scrollCtrl.hasClients) {\n      final pos = _scrollCtrl.positions.last;\n      if (pos.minScrollExtent == pos.maxScrollExtent) _loadNextPage();\n    }\n  }\n\n  void _scrollListener() {\n    final pos = _scrollCtrl.positions.last;\n    if (pos.pixels < pos.maxScrollExtent - 100) return;\n    if (_lastMaxExtent == pos.maxScrollExtent) return;\n\n    _lastMaxExtent = pos.maxScrollExtent;\n    _loadNextPage();\n  }\n\n  void _loadNextPage() {\n    final index = widget.withOverview ? widget.tabCtrl.index : widget.tabCtrl.index + 1;\n\n    if (index > 0) {\n      ref.read(characterMediaProvider(widget.id).notifier).fetch(index == 1);\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    ref.watch(characterMediaProvider(widget.id).select((_) => null));\n\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    return TabBarView(\n      controller: widget.tabCtrl,\n      children: [\n        if (widget.withOverview)\n          ConstrainedView(\n            padded: false,\n            child: CharacterOverviewSubview.asFragment(\n              character: widget.character,\n              scrollCtrl: _scrollCtrl,\n              invalidate: () => ref.invalidate(characterProvider(widget.id)),\n              highContrast: widget.highContrast,\n            ),\n          ),\n        CharacterAnimeSubview(\n          id: widget.id,\n          scrollCtrl: _scrollCtrl,\n          highContrast: options.highContrast,\n        ),\n        CharacterMangaSubview(\n          id: widget.id,\n          scrollCtrl: _scrollCtrl,\n          highContrast: options.highContrast,\n        ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_entries_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/collection/collection_filter_model.dart';\nimport 'package:otraku/feature/collection/collection_filter_provider.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/collection/collection_provider.dart';\nimport 'package:otraku/feature/tag/tag_model.dart';\nimport 'package:otraku/feature/tag/tag_provider.dart';\n\nfinal collectionEntriesProvider = Provider.autoDispose.family<List<EntryList>, CollectionTag>((\n  ref,\n  CollectionTag tag,\n) {\n  final filter = ref.watch(collectionFilterProvider(tag));\n  final mediaFilter = filter.mediaFilter;\n  final search = filter.search.toLowerCase();\n\n  ref\n      .watch(collectionProvider(tag).notifier)\n      .ensureSorted(mediaFilter.sort, mediaFilter.previewSort);\n\n  final lists = switch (ref.watch(collectionProvider(tag)).unwrapPrevious().value) {\n    PreviewCollection c => [c.list],\n    FullCollection c => c.index < 0 ? c.lists : [c.lists[c.index]],\n    null => const <EntryList>[],\n  };\n\n  final tags = ref.watch(tagsProvider).value;\n\n  return _filter(lists, mediaFilter, search, tags);\n});\n\nList<EntryList> _filter(\n  List<EntryList> lists,\n  CollectionMediaFilter mediaFilter,\n  String search,\n  TagCollection? tags,\n) {\n  final filteredLists = <EntryList>[];\n  final releaseStartFrom = mediaFilter.startYearFrom != null\n      ? DateTime(mediaFilter.startYearFrom!)\n      : DateTime(1920);\n  final releaseStartTo = mediaFilter.startYearTo != null\n      ? DateTime(mediaFilter.startYearTo! + 1)\n      : DateTime.now().add(const Duration(days: 900));\n\n  var tagIdIn = const <int>[];\n  var tagIdNotIn = const <int>[];\n  if (tags != null) {\n    final tagFinder = (String name) => tags.ids[tags.indexByName[name] ?? 0];\n    tagIdIn = mediaFilter.tagIn.map(tagFinder).toList();\n    tagIdNotIn = mediaFilter.tagNotIn.map(tagFinder).toList();\n  }\n\n  for (final l in lists) {\n    final entries = <Entry>[];\n\n    for (final entry in l.entries) {\n      if (search.isNotEmpty) {\n        bool contains = false;\n        for (final title in entry.titles) {\n          if (title.toLowerCase().contains(search)) {\n            contains = true;\n            break;\n          }\n        }\n\n        if (!contains && entry.notes.toLowerCase().contains(search)) {\n          contains = true;\n        }\n\n        if (!contains) continue;\n      }\n\n      if (mediaFilter.country != null && entry.country != mediaFilter.country!.code) {\n        continue;\n      }\n\n      if (mediaFilter.formats.isNotEmpty && !mediaFilter.formats.contains(entry.format)) {\n        continue;\n      }\n\n      if (mediaFilter.statuses.isNotEmpty && !mediaFilter.statuses.contains(entry.releaseStatus)) {\n        continue;\n      }\n\n      if (entry.releaseStart != null) {\n        if (releaseStartFrom.isAfter(entry.releaseStart!)) continue;\n        if (releaseStartTo.isBefore(entry.releaseStart!)) continue;\n      }\n\n      if (mediaFilter.genreIn.isNotEmpty) {\n        bool isIn = true;\n        for (final genre in mediaFilter.genreIn) {\n          if (!entry.genres.contains(genre)) {\n            isIn = false;\n            break;\n          }\n        }\n        if (!isIn) continue;\n      }\n\n      if (mediaFilter.genreNotIn.isNotEmpty) {\n        bool isIn = false;\n        for (final genre in mediaFilter.genreNotIn) {\n          if (entry.genres.contains(genre)) {\n            isIn = true;\n            break;\n          }\n        }\n        if (isIn) continue;\n      }\n\n      if (tagIdIn.isNotEmpty) {\n        bool isIn = true;\n        for (final tagId in tagIdIn) {\n          if (!entry.tagIds.contains(tagId)) {\n            isIn = false;\n            break;\n          }\n        }\n        if (!isIn) continue;\n      }\n\n      if (tagIdNotIn.isNotEmpty) {\n        bool isIn = false;\n        for (final tagId in tagIdNotIn) {\n          if (entry.tagIds.contains(tagId)) {\n            isIn = true;\n            break;\n          }\n        }\n        if (isIn) continue;\n      }\n\n      if (mediaFilter.isPrivate != null && entry.isPrivate != mediaFilter.isPrivate) {\n        continue;\n      }\n\n      if (mediaFilter.hasNotes != null && entry.notes.isNotEmpty != mediaFilter.hasNotes) {\n        continue;\n      }\n\n      entries.add(entry);\n    }\n\n    if (entries.isNotEmpty) {\n      filteredLists.add(l.copyWithEntries(entries));\n    }\n  }\n\n  return filteredLists;\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_filter_model.dart",
    "content": "import 'package:otraku/extension/enum_extension.dart';\nimport 'package:otraku/feature/media/media_models.dart';\n\nclass CollectionFilter {\n  const CollectionFilter._({required this.search, required this.mediaFilter});\n\n  CollectionFilter(this.mediaFilter) : search = '';\n\n  final String search;\n  final CollectionMediaFilter mediaFilter;\n\n  CollectionFilter copyWith({String? search, CollectionMediaFilter? mediaFilter}) =>\n      CollectionFilter._(\n        search: search ?? this.search,\n        mediaFilter: mediaFilter ?? this.mediaFilter,\n      );\n}\n\nclass CollectionMediaFilter {\n  CollectionMediaFilter() : sort = .title, previewSort = .title;\n\n  factory CollectionMediaFilter.fromPersistenceMap(Map<dynamic, dynamic> map) {\n    final sort = EntrySort.values.getOrFirst(map['sort']);\n    final previewSort = EntrySort.values.getOrFirst(map['previewSort']);\n\n    final filter = CollectionMediaFilter()\n      ..sort = sort\n      ..previewSort = previewSort\n      ..startYearFrom = map['startYearFrom']\n      ..startYearTo = map['startYearTo']\n      ..country = OriginCountry.values.getOrNull(map['country'])\n      ..isPrivate = map['isPrivate']\n      ..hasNotes = map['hasNotes'];\n\n    for (final e in map['statuses'] ?? const []) {\n      final status = ReleaseStatus.values.getOrNull(e);\n      if (status != null) {\n        filter.statuses.add(status);\n      }\n    }\n\n    for (final e in map['formats'] ?? const []) {\n      final format = MediaFormat.values.getOrNull(e);\n      if (format != null) {\n        filter.formats.add(format);\n      }\n    }\n\n    filter.genreIn.addAll(map['genreIn'] ?? const []);\n    filter.genreNotIn.addAll(map['genreNotIn'] ?? const []);\n    filter.tagIn.addAll(map['tagIn'] ?? const []);\n    filter.tagNotIn.addAll(map['tagNotIn'] ?? const []);\n\n    return filter;\n  }\n\n  final statuses = <ReleaseStatus>[];\n  final formats = <MediaFormat>[];\n  final genreIn = <String>[];\n  final genreNotIn = <String>[];\n  final tagIn = <String>[];\n  final tagNotIn = <String>[];\n  EntrySort sort;\n  EntrySort previewSort;\n  int? startYearFrom;\n  int? startYearTo;\n  OriginCountry? country;\n  bool? isPrivate;\n  bool? hasNotes;\n\n  bool get isActive =>\n      statuses.isNotEmpty ||\n      formats.isNotEmpty ||\n      genreIn.isNotEmpty ||\n      genreNotIn.isNotEmpty ||\n      tagIn.isNotEmpty ||\n      tagNotIn.isNotEmpty ||\n      startYearFrom != null ||\n      startYearTo != null ||\n      country != null ||\n      isPrivate != null ||\n      hasNotes != null;\n\n  CollectionMediaFilter copy() => CollectionMediaFilter()\n    ..sort = sort\n    ..previewSort = previewSort\n    ..statuses.addAll(statuses)\n    ..formats.addAll(formats)\n    ..genreIn.addAll(genreIn)\n    ..genreNotIn.addAll(genreNotIn)\n    ..tagIn.addAll(tagIn)\n    ..tagNotIn.addAll(tagNotIn)\n    ..startYearFrom = startYearFrom\n    ..startYearTo = startYearTo\n    ..country = country\n    ..isPrivate = isPrivate\n    ..hasNotes = hasNotes;\n\n  Map<String, dynamic> toPersistenceMap() => {\n    'statuses': statuses.map((e) => e.index).toList(),\n    'formats': formats.map((e) => e.index).toList(),\n    'genreIn': genreIn,\n    'genreNotIn': genreNotIn,\n    'tagIn': tagIn,\n    'tagNotIn': tagNotIn,\n    'sort': sort.index,\n    'previewSort': previewSort.index,\n    'startYearFrom': startYearFrom,\n    'startYearTo': startYearTo,\n    'country': country?.index,\n    'isPrivate': isPrivate,\n    'hasNotes': hasNotes,\n  };\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_filter_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/collection/collection_filter_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\n\nfinal collectionFilterProvider = NotifierProvider.autoDispose\n    .family<CollectionFilterNotifier, CollectionFilter, CollectionTag>(\n      CollectionFilterNotifier.new,\n    );\n\nclass CollectionFilterNotifier extends Notifier<CollectionFilter> {\n  CollectionFilterNotifier(this.arg);\n\n  final CollectionTag arg;\n\n  @override\n  CollectionFilter build() {\n    final mediaFilter = ref.watch(\n      persistenceProvider.select(\n        (s) => arg.ofAnime ? s.animeCollectionMediaFilter : s.mangaCollectionMediaFilter,\n      ),\n    );\n\n    return CollectionFilter(mediaFilter.copy());\n  }\n\n  CollectionFilter update(CollectionFilter Function(CollectionFilter) callback) =>\n      state = callback(state);\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_filter_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/collection/collection_filter_model.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/feature/tag/tag_picker.dart';\nimport 'package:otraku/widget/input/year_range_picker.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/tag/tag_provider.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/navigation_tool.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass CollectionFilterView extends ConsumerStatefulWidget {\n  const CollectionFilterView({required this.tag, required this.filter, required this.onChanged});\n\n  final CollectionTag tag;\n  final CollectionMediaFilter filter;\n  final void Function(CollectionMediaFilter) onChanged;\n\n  @override\n  ConsumerState<CollectionFilterView> createState() => _FilterCollectionViewState();\n}\n\nclass _FilterCollectionViewState extends ConsumerState<CollectionFilterView> {\n  late final _filter = widget.filter.copy();\n\n  @override\n  Widget build(BuildContext context) {\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n    final ofViewer = ref.watch(viewerIdProvider) == widget.tag.userId;\n\n    final applyButton = BottomBarButton(\n      text: 'Apply',\n      icon: Icons.done_rounded,\n      onTap: () {\n        widget.onChanged(_filter);\n        Navigator.pop(context);\n      },\n    );\n\n    final revertToDefaultButton = BottomBarButton(\n      text: 'Reset',\n      icon: Icons.restore_rounded,\n      foregroundColor: ColorScheme.of(context).secondary,\n      onTap: () {\n        final persistence = ref.read(persistenceProvider);\n        if (widget.tag.ofAnime) {\n          widget.onChanged(persistence.animeCollectionMediaFilter);\n        } else {\n          widget.onChanged(persistence.mangaCollectionMediaFilter);\n        }\n\n        Navigator.pop(context);\n      },\n    );\n\n    final saveButton = BottomBarButton(\n      text: 'Save',\n      icon: Icons.save_outlined,\n      foregroundColor: ColorScheme.of(context).secondary,\n      onTap: () => ConfirmationDialog.show(\n        context,\n        title: 'Make default?',\n        content: 'The current filters and sorting will become the default.',\n        primaryAction: 'Yes',\n        secondaryAction: 'No',\n        onConfirm: () {\n          final notifier = ref.read(persistenceProvider.notifier);\n          if (widget.tag.ofAnime) {\n            notifier.setAnimeCollectionMediaFilter(_filter);\n          } else {\n            notifier.setMangaCollectionMediaFilter(_filter);\n          }\n\n          widget.onChanged(_filter);\n          Navigator.pop(context);\n        },\n      ),\n    );\n\n    Widget? previewSortPicker;\n    if (ofViewer &&\n        (widget.tag.ofAnime && options.animeCollectionPreview ||\n            !widget.tag.ofAnime && options.mangaCollectionPreview)) {\n      previewSortPicker = EntrySortChipSelector(\n        title: 'Preview Sorting',\n        value: _filter.previewSort,\n        onChanged: (v) => _filter.previewSort = v,\n        highContrast: options.highContrast,\n      );\n    }\n\n    return SheetWithButtonRow(\n      buttons: BottomBar(\n        Theming.of(context).rightButtonOrientation\n            ? [saveButton, revertToDefaultButton, applyButton]\n            : [applyButton, revertToDefaultButton, saveButton],\n      ),\n      builder: (context, scrollCtrl) => Padding(\n        padding: const .symmetric(horizontal: Theming.offset),\n        child: ListView(\n          controller: scrollCtrl,\n          padding: const .only(top: 20),\n          children: [\n            EntrySortChipSelector(\n              title: 'Sorting',\n              value: _filter.sort,\n              onChanged: (v) => _filter.sort = v,\n              highContrast: options.highContrast,\n            ),\n            ?previewSortPicker,\n            ChipMultiSelector(\n              title: 'Statuses',\n              items: ReleaseStatus.values.map((v) => (v.label, v)).toList(),\n              values: _filter.statuses,\n              highContrast: options.highContrast,\n            ),\n            ChipMultiSelector(\n              title: 'Formats',\n              items: (widget.tag.ofAnime ? MediaFormat.animeFormats : MediaFormat.mangaFormats)\n                  .map((v) => (v.label, v))\n                  .toList(),\n              values: _filter.formats,\n              highContrast: options.highContrast,\n            ),\n            const SizedBox(height: 5),\n            const Divider(),\n            switch (ref.watch(tagsProvider)) {\n              AsyncData() => TagPicker(\n                includedGenres: _filter.genreIn,\n                excludedGenres: _filter.genreNotIn,\n                includedTags: _filter.tagIn,\n                excludedTags: _filter.tagNotIn,\n              ),\n              AsyncError(:final error) => Center(\n                child: Padding(\n                  padding: Theming.paddingAll,\n                  child: Text('Failed to load tags: $error'),\n                ),\n              ),\n              AsyncLoading() => const Center(\n                child: Padding(padding: Theming.paddingAll, child: Loader()),\n              ),\n            },\n            const Divider(),\n            const SizedBox(height: Theming.offset),\n            YearRangePicker(\n              title: 'Release Year Range',\n              from: _filter.startYearFrom,\n              to: _filter.startYearTo,\n              onChanged: (from, to) {\n                _filter.startYearFrom = from;\n                _filter.startYearTo = to;\n              },\n            ),\n            const SizedBox(height: Theming.offset),\n            const Divider(),\n            ChipSelector(\n              title: 'Country',\n              items: OriginCountry.values.map((v) => (v.label, v)).toList(),\n              value: _filter.country,\n              onChanged: (v) => _filter.country = v,\n              highContrast: options.highContrast,\n            ),\n            if (ofViewer)\n              ChipSelector(\n                title: 'Visibility',\n                items: const [('Private', true), ('Public', false)],\n                value: _filter.isPrivate,\n                onChanged: (v) => _filter.isPrivate = v,\n                highContrast: options.highContrast,\n              ),\n            ChipSelector(\n              title: 'Notes',\n              items: const [('With Notes', true), ('Without Notes', false)],\n              value: _filter.hasNotes,\n              onChanged: (v) => _filter.hasNotes = v,\n              highContrast: options.highContrast,\n            ),\n            SizedBox(\n              height: MediaQuery.paddingOf(context).bottom + BottomBar.height + Theming.offset,\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_floating_action.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/collection/collection_provider.dart';\nimport 'package:otraku/feature/home/home_provider.dart';\nimport 'package:otraku/widget/input/pill_selector.dart';\nimport 'package:otraku/widget/swipe_switcher.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass CollectionFloatingAction extends StatelessWidget {\n  CollectionFloatingAction(this.tag) : super(key: Key('${tag.userId}${tag.ofAnime}'));\n\n  final CollectionTag tag;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        final collection = ref.watch(\n          collectionProvider(tag).select((s) => s.unwrapPrevious().value),\n        );\n\n        return switch (collection) {\n          null => const SizedBox(),\n          PreviewCollection _ => FloatingActionButton(\n            tooltip: 'Load Entire Collection',\n            child: const Icon(Ionicons.enter_outline),\n            onPressed: () => ref.read(homeProvider.notifier).expandCollection(tag.ofAnime),\n          ),\n          FullCollection c => _fullCollectionActionButton(context, ref, c.lists, c.index),\n        };\n      },\n    );\n  }\n\n  Widget _fullCollectionActionButton(\n    BuildContext context,\n    WidgetRef ref,\n    List<EntryList> lists,\n    int index,\n  ) {\n    final items = buildFullCollectionSelectionItems(context, lists);\n\n    return FloatingActionButton(\n      tooltip: 'Lists',\n      onPressed: () {\n        showSheet(\n          context,\n          SimpleSheet(\n            initialHeight: PillSelector.expectedMinHeight(lists.length),\n            builder: (context, scrollCtrl) => PillSelector(\n              scrollCtrl: scrollCtrl,\n              selected: index + 1,\n              items: items,\n              onTap: (index) {\n                ref.read(collectionProvider(tag).notifier).changeIndex(index - 1);\n                Navigator.pop(context);\n              },\n            ),\n          ),\n        );\n      },\n      child: SwipeSwitcher(\n        index: index + 1,\n        children: List.filled(lists.length + 1, const Icon(Ionicons.menu_outline)),\n        onChanged: (index) => ref.read(collectionProvider(tag).notifier).changeIndex(index - 1),\n      ),\n    );\n  }\n}\n\nList<Widget> buildFullCollectionSelectionItems(BuildContext context, List<EntryList> lists) {\n  final listItems = [\n    (name: 'All', count: lists.fold(0, (v, l) => v + l.entries.length).toString()),\n    ...lists.map((l) => (name: l.name, count: l.entries.length.toString())),\n  ];\n\n  final listItemToWidget = (({String name, String count}) item) => Row(\n    spacing: 5,\n    children: [\n      Expanded(child: Text(item.name)),\n      Text(item.count, style: TextTheme.of(context).labelMedium),\n    ],\n  );\n\n  return listItems.map(listItemToWidget).toList();\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/edit/edit_view.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/util/debounce.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass CollectionGrid extends StatelessWidget {\n  const CollectionGrid({\n    required this.items,\n    required this.onProgressUpdated,\n    required this.highContrast,\n  });\n\n  final List<Entry> items;\n  final Future<String?> Function(Entry, bool)? onProgressUpdated;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final extraHeight = lineHeight * 2 + 38;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight(\n        minWidth: 100,\n        extraHeight: extraHeight,\n        rawHWRatio: Theming.coverHtoWRatio,\n      ),\n      delegate: SliverChildBuilderDelegate(\n        childCount: items.length,\n        (context, i) => CardExtension.highContrast(highContrast)(\n          child: MediaRouteTile(\n            id: items[i].mediaId,\n            imageUrl: items[i].imageUrl,\n            child: Column(\n              crossAxisAlignment: .stretch,\n              children: [\n                Expanded(\n                  child: ClipRRect(\n                    borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall),\n                    child: Container(\n                      color: ColorScheme.of(context).surfaceContainerHighest,\n                      child: CachedImage(items[i].imageUrl),\n                    ),\n                  ),\n                ),\n                SizedBox(\n                  height: lineHeight * 2 + 8,\n                  child: Padding(\n                    padding: const .only(left: 5, right: 5, top: 5, bottom: 3),\n                    child: Text(items[i].titles[0], overflow: .ellipsis, maxLines: 2),\n                  ),\n                ),\n                _IncrementButton(items[i], onProgressUpdated),\n              ],\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass _IncrementButton extends StatefulWidget {\n  const _IncrementButton(this.item, this.onProgressUpdated);\n\n  final Entry item;\n  final Future<String?> Function(Entry, bool)? onProgressUpdated;\n\n  @override\n  State<_IncrementButton> createState() => _IncrementButtonState();\n}\n\nclass _IncrementButtonState extends State<_IncrementButton> {\n  final _debounce = Debounce();\n  int? _lastProgress;\n\n  @override\n  Widget build(BuildContext context) {\n    final item = widget.item;\n\n    if (item.progress == item.progressMax) {\n      return Tooltip(\n        message: 'Progress',\n        child: SizedBox(\n          height: 30,\n          child: Center(\n            child: Text(item.progress.toString(), style: TextTheme.of(context).labelSmall),\n          ),\n        ),\n      );\n    }\n\n    final foregroundColor = item.nextEpisode != null && item.progress + 1 < item.nextEpisode!\n        ? ColorScheme.of(context).error\n        : null;\n\n    if (widget.onProgressUpdated == null) {\n      return Tooltip(\n        message: 'Progress',\n        child: SizedBox(\n          height: 30,\n          child: Center(\n            child: Text(\n              '${item.progress}/${item.progressMax ?? \"?\"}',\n              style: TextTheme.of(context).labelSmall?.copyWith(color: foregroundColor),\n            ),\n          ),\n        ),\n      );\n    }\n\n    return TextButton(\n      style: TextButton.styleFrom(\n        minimumSize: const Size(0, 30),\n        padding: const .symmetric(horizontal: 5),\n        tapTargetSize: MaterialTapTargetSize.shrinkWrap,\n        foregroundColor: foregroundColor,\n        iconColor: foregroundColor,\n      ),\n      onPressed: () {\n        _debounce.cancel();\n\n        if (item.progressMax != null && item.progress >= item.progressMax! - 1) {\n          _resetProgress();\n\n          showSheet(context, EditView((id: item.mediaId, setComplete: true)));\n          return;\n        }\n\n        _lastProgress ??= item.progress;\n        setState(() => item.progress++);\n\n        _debounce.run(_update);\n      },\n      child: Tooltip(\n        message: 'Increment Progress',\n        child: Row(\n          mainAxisAlignment: .center,\n          children: [\n            Text(\n              '${item.progress}/${item.progressMax ?? \"?\"}',\n              style: const TextStyle(fontSize: Theming.fontSmall),\n            ),\n            const SizedBox(width: 3),\n            const Icon(Ionicons.add_outline, size: Theming.iconSmall),\n          ],\n        ),\n      ),\n    );\n  }\n\n  void _update() async {\n    final item = widget.item;\n    var updateStatus = false;\n\n    if (_lastProgress == 0 &&\n        (item.listStatus == .planning ||\n            item.listStatus == .paused ||\n            item.listStatus == .dropped)) {\n      await ConfirmationDialog.show(\n        context,\n        title: 'Update status?',\n        content: 'Do you also want to update the list status?',\n        primaryAction: 'Yes',\n        secondaryAction: 'No',\n        onConfirm: () => updateStatus = true,\n      );\n    }\n\n    final err = await widget.onProgressUpdated!(item, updateStatus);\n    if (err == null) {\n      _lastProgress = null;\n      return;\n    }\n\n    _resetProgress();\n    if (mounted) {\n      SnackBarExtension.show(context, 'Failed updating progress: $err');\n    }\n  }\n\n  void _resetProgress() {\n    if (_lastProgress == null) return;\n\n    setState(() => widget.item.progress = _lastProgress!);\n    _lastProgress = null;\n  }\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_list.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/util/debounce.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/edit/edit_view.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/input/note_label.dart';\nimport 'package:otraku/widget/input/score_label.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/widget/text_rail.dart';\nimport 'package:otraku/feature/media/media_models.dart';\n\nclass CollectionList extends StatelessWidget {\n  const CollectionList({\n    required this.items,\n    required this.scoreFormat,\n    required this.onProgressUpdated,\n    required this.highContrast,\n  });\n\n  final List<Entry> items;\n  final ScoreFormat scoreFormat;\n  final Future<String?> Function(Entry, bool)? onProgressUpdated;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final textTheme = TextTheme.of(context);\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);\n    final tileHeight = bodyMediumLineHeight * 2 + labelMediumLineHeight * 2 + Theming.offset + 69;\n\n    return SliverFixedExtentList(\n      delegate: SliverChildBuilderDelegate(\n        (_, i) => _Tile(\n          items[i],\n          scoreFormat,\n          onProgressUpdated,\n          highContrast,\n          tileHeight / Theming.coverHtoWRatio,\n        ),\n        childCount: items.length,\n      ),\n      itemExtent: tileHeight,\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile(\n    this.entry,\n    this.scoreFormat,\n    this.onProgressUpdated,\n    this.highContrast,\n    this.coverWidth,\n  );\n\n  final Entry entry;\n  final ScoreFormat scoreFormat;\n  final Future<String?> Function(Entry, bool)? onProgressUpdated;\n  final bool highContrast;\n  final double coverWidth;\n\n  @override\n  Widget build(BuildContext context) {\n    return CardExtension.highContrast(highContrast)(\n      margin: const .only(bottom: Theming.offset),\n      child: MediaRouteTile(\n        key: ValueKey(entry.mediaId),\n        id: entry.mediaId,\n        imageUrl: entry.imageUrl,\n        child: Row(\n          crossAxisAlignment: .start,\n          children: [\n            ClipRRect(\n              borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n              child: DecoratedBox(\n                decoration: BoxDecoration(color: ColorScheme.of(context).surfaceContainerHighest),\n                child: CachedImage(entry.imageUrl, width: coverWidth),\n              ),\n            ),\n            Expanded(\n              child: Padding(\n                padding: Theming.paddingAll,\n                child: _TileContent(entry, scoreFormat, onProgressUpdated),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n\n/// The content is a [StatefulWidget], as it\n/// needs to update when the progress increments.\nclass _TileContent extends StatefulWidget {\n  const _TileContent(this.item, this.scoreFormat, this.onProgressUpdated);\n\n  final Entry item;\n  final ScoreFormat scoreFormat;\n  final Future<String?> Function(Entry, bool)? onProgressUpdated;\n\n  @override\n  State<_TileContent> createState() => __TileContentState();\n}\n\nclass __TileContentState extends State<_TileContent> {\n  final _debounce = Debounce();\n  int? _lastProgress;\n\n  @override\n  Widget build(BuildContext context) {\n    final colorScheme = ColorScheme.of(context);\n    final item = widget.item;\n\n    double progressPercent = 0;\n    if (item.progressMax != null) {\n      progressPercent = item.progress / item.progressMax!;\n    } else if (item.nextEpisode != null) {\n      progressPercent = item.progress / (item.nextEpisode! - 1);\n    } else if (item.progress > 0) {\n      progressPercent = 1;\n    }\n\n    final textRailItems = <String, bool>{};\n    if (item.format != null) {\n      textRailItems[item.format!.label] = false;\n    }\n\n    if (item.airingAt != null) {\n      final key = 'Ep ${item.nextEpisode} in ${item.airingAt!.timeUntil}';\n      textRailItems[key] = false;\n    }\n\n    if (item.nextEpisode != null && item.nextEpisode! - 1 > item.progress) {\n      final key = '${item.nextEpisode! - 1 - item.progress} ep behind';\n      textRailItems[key] = true;\n    }\n\n    return Column(\n      mainAxisAlignment: .spaceAround,\n      crossAxisAlignment: .stretch,\n      children: [\n        Flexible(child: Text(widget.item.titles[0], overflow: .ellipsis, maxLines: 2)),\n        TextRail(textRailItems, maxLines: 2),\n        Padding(\n          padding: const EdgeInsets.symmetric(vertical: 3),\n          child: SizedBox(\n            height: 3,\n            child: DecoratedBox(\n              decoration: BoxDecoration(\n                borderRadius: Theming.borderRadiusSmall,\n                gradient: LinearGradient(\n                  colors: [\n                    colorScheme.onSurfaceVariant,\n                    colorScheme.onSurfaceVariant,\n                    colorScheme.surfaceContainerHighest,\n                    colorScheme.surfaceContainerHighest,\n                  ],\n                  stops: [0.0, progressPercent, progressPercent, 1.0],\n                ),\n              ),\n            ),\n          ),\n        ),\n        Row(\n          mainAxisAlignment: .spaceBetween,\n          children: [\n            ScoreLabel(item.score, widget.scoreFormat),\n            if (item.repeat > 0)\n              Tooltip(\n                message: 'Repeats',\n                child: Row(\n                  mainAxisSize: .min,\n                  spacing: 3,\n                  children: [\n                    const Icon(Ionicons.repeat, size: Theming.iconSmall),\n                    Text(item.repeat.toString(), style: TextTheme.of(context).labelSmall),\n                  ],\n                ),\n              )\n            else\n              const SizedBox(),\n            NotesLabel(item.notes),\n            _buildProgressButton(context),\n          ],\n        ),\n      ],\n    );\n  }\n\n  Widget _buildProgressButton(BuildContext context) {\n    final item = widget.item;\n    final foregroundColor = item.nextEpisode != null && item.progress + 1 < item.nextEpisode!\n        ? ColorScheme.of(context).error\n        : ColorScheme.of(context).onSurfaceVariant;\n\n    final text = Text(\n      item.progress == item.progressMax\n          ? item.progress.toString()\n          : '${item.progress}/${item.progressMax ?? \"?\"}',\n      style: TextTheme.of(context).labelSmall?.copyWith(color: foregroundColor),\n    );\n\n    if (widget.onProgressUpdated == null || item.progress == item.progressMax) {\n      return Tooltip(message: 'Progress', child: text);\n    }\n\n    return TextButton(\n      style: TextButton.styleFrom(\n        minimumSize: const Size(0, 40),\n        tapTargetSize: MaterialTapTargetSize.shrinkWrap,\n        foregroundColor: foregroundColor,\n        iconColor: foregroundColor,\n      ),\n      onPressed: () {\n        _debounce.cancel();\n\n        if (item.progressMax != null && item.progress >= item.progressMax! - 1) {\n          _resetProgress();\n\n          showSheet(context, EditView((id: item.mediaId, setComplete: true)));\n          return;\n        }\n\n        _lastProgress ??= item.progress;\n        setState(() => item.progress++);\n\n        _debounce.run(_update);\n      },\n      child: Tooltip(\n        message: 'Increment Progress',\n        child: Row(\n          spacing: 3,\n          children: [\n            text,\n            const Icon(Ionicons.add_outline, size: Theming.iconSmall),\n          ],\n        ),\n      ),\n    );\n  }\n\n  void _update() async {\n    final item = widget.item;\n    var updateStatus = false;\n\n    if (_lastProgress == 0 &&\n        (item.listStatus == .planning ||\n            item.listStatus == .paused ||\n            item.listStatus == .dropped)) {\n      await ConfirmationDialog.show(\n        context,\n        title: 'Update status?',\n        content: 'Do you also want to update the list status?',\n        primaryAction: 'Yes',\n        secondaryAction: 'No',\n        onConfirm: () => updateStatus = true,\n      );\n    }\n\n    final err = await widget.onProgressUpdated!(item, updateStatus);\n    if (err == null) {\n      _lastProgress = null;\n      return;\n    }\n\n    _resetProgress();\n    if (mounted) {\n      SnackBarExtension.show(context, 'Failed updating progress: $err');\n    }\n  }\n\n  void _resetProgress() {\n    if (_lastProgress == null) return;\n\n    setState(() => widget.item.progress = _lastProgress!);\n    _lastProgress = null;\n  }\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_models.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/extension/iterable_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/feature/media/media_models.dart';\n\ntypedef CollectionTag = ({int userId, bool ofAnime});\n\nenum CollectionItemView { detailed, simple }\n\nsealed class Collection {\n  const Collection({required this.scoreFormat});\n\n  final ScoreFormat scoreFormat;\n\n  String get listName;\n\n  void sort(EntrySort s);\n}\n\nclass PreviewCollection extends Collection {\n  const PreviewCollection._({required this.list, required super.scoreFormat});\n\n  factory PreviewCollection(Map<String, dynamic> map, ImageQuality imageQuality) {\n    final entries = <Entry>[];\n    for (final l in map['lists']) {\n      if (l['isCustomList']) continue;\n\n      for (final e in l['entries']) {\n        entries.add(Entry(e, imageQuality));\n      }\n    }\n\n    return PreviewCollection._(\n      list: EntryList._(\n        name: 'Preview',\n        entries: entries,\n        status: null,\n        splitCompletedListFormat: null,\n      ),\n      scoreFormat: ScoreFormat.from(map['user']['mediaListOptions']['scoreFormat']),\n    );\n  }\n\n  final EntryList list;\n\n  @override\n  String get listName => 'Preview';\n\n  @override\n  void sort(EntrySort s) {\n    list.entries.sort(_entryComparator(s));\n  }\n}\n\nclass FullCollection extends Collection {\n  const FullCollection._({required this.lists, required this.index, required super.scoreFormat});\n\n  factory FullCollection(\n    Map<String, dynamic> map,\n    bool ofAnime,\n    int index,\n    ImageQuality imageQuality,\n  ) {\n    final maps = map['lists'] as List<dynamic>;\n    final lists = <EntryList>[];\n    final metaData = map['user']['mediaListOptions'][ofAnime ? 'animeList' : 'mangaList'];\n    bool splitCompleted = metaData['splitCompletedSectionByFormat'] ?? false;\n\n    for (final String section in metaData['sectionOrder']) {\n      final pos = maps.indexWhere((l) => l['name'] == section);\n      if (pos == -1) continue;\n\n      final l = maps.removeAt(pos);\n\n      lists.add(EntryList(l, splitCompleted, imageQuality));\n    }\n\n    for (final l in maps) {\n      lists.add(EntryList(l, splitCompleted, imageQuality));\n    }\n\n    if (index >= lists.length) index = 0;\n\n    return FullCollection._(\n      lists: lists,\n      index: index,\n      scoreFormat: ScoreFormat.from(map['user']['mediaListOptions']['scoreFormat']),\n    );\n  }\n\n  final List<EntryList> lists;\n  final int index;\n\n  @override\n  String get listName => index < 0 ? 'All' : lists[index].name;\n\n  @override\n  void sort(EntrySort s) {\n    final comparator = _entryComparator(s);\n    for (final l in lists) {\n      l.entries.sort(comparator);\n    }\n  }\n\n  FullCollection withIndex(int newIndex) => newIndex == index\n      ? this\n      : FullCollection._(lists: lists, index: newIndex, scoreFormat: scoreFormat);\n}\n\nclass EntryList {\n  const EntryList._({\n    required this.name,\n    required this.entries,\n    required this.status,\n    required this.splitCompletedListFormat,\n  });\n\n  factory EntryList(Map<String, dynamic> map, bool splitCompleted, ImageQuality imageQuality) {\n    final status = !map['isCustomList'] ? ListStatus.from(map['status']) : null;\n\n    return EntryList._(\n      name: map['name'],\n      status: status,\n      splitCompletedListFormat: splitCompleted && status == .completed\n          ? MediaFormat.from(map['entries'][0]['media']['format'])\n          : null,\n      entries: (map['entries'] as List<dynamic>).map((e) => Entry(e, imageQuality)).toList(),\n    );\n  }\n\n  final String name;\n  final List<Entry> entries;\n\n  /// The [ListStatus] of the [entries] in this list.\n  /// If `null`, this is a custom list.\n  final ListStatus? status;\n\n  /// If the user's \"completed\" list is split by format and this is one of the\n  /// resulting lists, [splitCompletedListFormat] is the corresponding format.\n  final MediaFormat? splitCompletedListFormat;\n\n  bool setByMediaId(Entry entry) {\n    for (int i = 0; i < entries.length; i++) {\n      if (entries[i].mediaId == entry.mediaId) {\n        entries[i] = entry;\n        return true;\n      }\n    }\n    return false;\n  }\n\n  void removeByMediaId(int id) {\n    for (int i = 0; i < entries.length; i++) {\n      if (entries[i].mediaId == id) {\n        entries.removeAt(i);\n        return;\n      }\n    }\n  }\n\n  void insertSorted(Entry entry, EntrySort s) {\n    final compare = _entryComparator(s);\n    for (int i = 0; i < entries.length; i++) {\n      if (compare(entry, entries[i]) <= 0) {\n        entries.insert(i, entry);\n        return;\n      }\n    }\n    entries.add(entry);\n  }\n\n  void sort(EntrySort s) => entries.sort(_entryComparator(s));\n\n  EntryList copyWithEntries(List<Entry> entries) => EntryList._(\n    name: name,\n    entries: entries,\n    status: status,\n    splitCompletedListFormat: splitCompletedListFormat,\n  );\n}\n\n/// Returns a [Comparator] for [Entry], based on an [EntrySort].\nint Function(Entry, Entry) _entryComparator(EntrySort s) => switch (s) {\n  .title => (a, b) => a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()),\n  .titleDesc => (a, b) => b.titles[0].compareTo(a.titles[0]),\n  .score => (a, b) {\n    final comparison = a.score.compareTo(b.score);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .scoreDesc => (a, b) {\n    final comparison = b.score.compareTo(a.score);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .updated => (a, b) {\n    final comparison = a.updatedAt!.compareTo(b.updatedAt!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .updatedDesc => (a, b) {\n    final comparison = b.updatedAt!.compareTo(a.updatedAt!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .added => (a, b) {\n    final comparison = a.createdAt!.compareTo(b.createdAt!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .addedDesc => (a, b) {\n    final comparison = b.createdAt!.compareTo(a.createdAt!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .progress => (a, b) {\n    final comparison = a.progress.compareTo(b.progress);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .progressDesc => (a, b) {\n    final comparison = b.progress.compareTo(a.progress);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .repeated => (a, b) {\n    final comparison = a.repeat.compareTo(b.repeat);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .repeatedDesc => (a, b) {\n    final comparison = b.repeat.compareTo(a.repeat);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .airing => (a, b) {\n    if (a.airingAt == null) {\n      if (b.airingAt == null) {\n        return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n      }\n      return 1;\n    }\n\n    if (b.airingAt == null) return -1;\n\n    final comparison = a.airingAt!.compareTo(b.airingAt!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .airingDesc => (a, b) {\n    if (b.airingAt == null) {\n      if (a.airingAt == null) {\n        return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n      }\n      return -1;\n    }\n\n    if (a.airingAt == null) return 1;\n\n    final comparison = b.airingAt!.compareTo(a.airingAt!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .releasedOn => (a, b) {\n    if (a.releaseStart == null) {\n      if (b.releaseStart == null) {\n        return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n      }\n      return 1;\n    }\n\n    if (b.releaseStart == null) return -1;\n\n    final comparison = a.releaseStart!.compareTo(b.releaseStart!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .releasedOnDesc => (a, b) {\n    if (b.releaseStart == null) {\n      if (a.releaseStart == null) {\n        return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n      }\n      return -1;\n    }\n\n    if (a.releaseStart == null) return 1;\n\n    final comparison = b.releaseStart!.compareTo(a.releaseStart!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .startedOn => (a, b) {\n    if (a.watchStart == null) {\n      if (b.watchStart == null) {\n        return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n      }\n      return 1;\n    }\n\n    if (b.watchStart == null) return -1;\n\n    final comparison = a.watchStart!.compareTo(b.watchStart!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .startedOnDesc => (a, b) {\n    if (b.watchStart == null) {\n      if (a.watchStart == null) {\n        return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n      }\n      return -1;\n    }\n\n    if (a.watchStart == null) return 1;\n\n    final comparison = b.watchStart!.compareTo(a.watchStart!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .completedOn => (a, b) {\n    if (a.watchEnd == null) {\n      if (b.watchEnd == null) {\n        return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n      }\n      return 1;\n    }\n\n    if (b.watchEnd == null) return -1;\n\n    final comparison = a.watchEnd!.compareTo(b.watchEnd!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .completedOnDesc => (a, b) {\n    if (b.watchEnd == null) {\n      if (a.watchEnd == null) {\n        return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n      }\n      return -1;\n    }\n\n    if (a.watchEnd == null) return 1;\n\n    final comparison = b.watchEnd!.compareTo(a.watchEnd!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .avgScore => (a, b) {\n    if (a.avgScore == null) {\n      if (b.avgScore == null) {\n        return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n      }\n      return 1;\n    }\n\n    if (b.avgScore == null) return -1;\n\n    final comparison = a.avgScore!.compareTo(b.avgScore!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n  .avgScoreDesc => (a, b) {\n    if (b.avgScore == null) {\n      if (a.avgScore == null) {\n        return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n      }\n      return -1;\n    }\n\n    if (a.avgScore == null) return 1;\n\n    final comparison = b.avgScore!.compareTo(a.avgScore!);\n    if (comparison != 0) return comparison;\n    return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());\n  },\n};\n\nclass Entry {\n  Entry._({\n    required this.mediaId,\n    required this.titles,\n    required this.imageUrl,\n    required this.format,\n    required this.releaseStatus,\n    required this.listStatus,\n    required this.nextEpisode,\n    required this.airingAt,\n    required this.createdAt,\n    required this.updatedAt,\n    required this.country,\n    required this.isPrivate,\n    required this.genres,\n    required this.tagIds,\n    required this.progressMax,\n    required this.progress,\n    required this.repeat,\n    required this.score,\n    required this.notes,\n    required this.avgScore,\n    required this.releaseStart,\n    required this.watchStart,\n    required this.watchEnd,\n  });\n\n  factory Entry(Map<String, dynamic> map, ImageQuality imageQuality) {\n    final titles = <String>[map['media']['title']['userPreferred']];\n    if (map['media']['title']['english'] != null) {\n      titles.add(map['media']['title']['english']);\n    }\n    if (map['media']['title']['romaji'] != null) {\n      titles.add(map['media']['title']['romaji']);\n    }\n    if (map['media']['title']['native'] != null) {\n      titles.add(map['media']['title']['native']);\n    }\n\n    final tagIds = <int>[];\n    for (final t in map['media']['tags']) {\n      tagIds.add(t['id']);\n    }\n\n    return Entry._(\n      mediaId: map['media']['id'],\n      titles: titles,\n      imageUrl: map['media']['coverImage'][imageQuality.value],\n      format: MediaFormat.from(map['media']['format']),\n      releaseStatus: ReleaseStatus.from(map['media']['status']),\n      listStatus: ListStatus.from(map['status']),\n      nextEpisode: map['media']['nextAiringEpisode']?['episode'],\n      airingAt: DateTimeExtension.tryFromSecondsSinceEpoch(\n        map['media']['nextAiringEpisode']?['airingAt'],\n      ),\n      createdAt: map['createdAt'],\n      updatedAt: map['updatedAt'],\n      country: map['media']['countryOfOrigin'],\n      isPrivate: map['private'] ?? false,\n      genres: List.from(map['media']['genres'] ?? [], growable: false),\n      tagIds: tagIds,\n      progressMax: map['media']['episodes'] ?? map['media']['chapters'],\n      progress: map['progress'] ?? 0,\n      repeat: map['repeat'] ?? 0,\n      score: map['score'].toDouble() ?? 0.0,\n      notes: map['notes'] ?? '',\n      avgScore: map['media']['averageScore'],\n      releaseStart: DateTimeExtension.fromFuzzyDate(map['media']['startDate']),\n      watchStart: DateTimeExtension.fromFuzzyDate(map['startedAt']),\n      watchEnd: DateTimeExtension.fromFuzzyDate(map['completedAt']),\n    );\n  }\n\n  final int mediaId;\n  final List<String> titles;\n  final String imageUrl;\n  final MediaFormat? format;\n  final ReleaseStatus? releaseStatus;\n  final ListStatus? listStatus;\n  final int? nextEpisode;\n  final DateTime? airingAt;\n  final int? createdAt;\n  final int? updatedAt;\n  final String? country;\n  final bool isPrivate;\n  final List<String> genres;\n  final List<int> tagIds;\n  final int? progressMax;\n  int progress;\n  int repeat;\n  double score;\n  String notes;\n  int? avgScore;\n  DateTime? releaseStart;\n  DateTime? watchStart;\n  DateTime? watchEnd;\n}\n\nenum ListStatus {\n  current('CURRENT'),\n  planning('PLANNING'),\n  completed('COMPLETED'),\n  dropped('DROPPED'),\n  paused('PAUSED'),\n  repeating('REPEATING');\n\n  const ListStatus(this.value);\n\n  final String value;\n\n  String label(bool? ofAnime) => switch (this) {\n    current =>\n      ofAnime == null\n          ? 'Current'\n          : ofAnime\n          ? 'Watching'\n          : 'Reading',\n    repeating =>\n      ofAnime == null\n          ? 'Repeating'\n          : ofAnime\n          ? 'Rewatching'\n          : 'Rereading',\n    completed => 'Completed',\n    paused => 'Paused',\n    planning => 'Planning',\n    dropped => 'Dropped',\n  };\n\n  static ListStatus? from(String? value) =>\n      ListStatus.values.firstWhereOrNull((v) => v.value == value);\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/home/home_provider.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal collectionProvider = AsyncNotifierProvider.autoDispose\n    .family<CollectionNotifier, Collection, CollectionTag>(CollectionNotifier.new);\n\nclass CollectionNotifier extends AsyncNotifier<Collection> {\n  CollectionNotifier(this.arg);\n\n  final CollectionTag arg;\n\n  var _sort = EntrySort.title;\n\n  @override\n  FutureOr<Collection> build() async {\n    final fullCollectionIndex = switch (state.value) {\n      FullCollection c => c.index,\n      _ => -1,\n    };\n\n    final viewerId = ref.watch(viewerIdProvider);\n\n    final isFull =\n        arg.userId != viewerId ||\n        ref.watch(\n          homeProvider.select(\n            (s) => arg.ofAnime ? s.didExpandAnimeCollection : s.didExpandMangaCollection,\n          ),\n        );\n\n    final data = await ref.read(repositoryProvider).request(GqlQuery.collection, {\n      'userId': arg.userId,\n      'type': arg.ofAnime ? 'ANIME' : 'MANGA',\n      if (!isFull) 'status_in': ['CURRENT', 'REPEATING'],\n    });\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    final collection = isFull\n        ? FullCollection(\n            data['MediaListCollection'],\n            arg.ofAnime,\n            fullCollectionIndex,\n            imageQuality,\n          )\n        : PreviewCollection(data['MediaListCollection'], imageQuality);\n    collection.sort(_sort);\n    return collection;\n  }\n\n  void ensureSorted(EntrySort sort, EntrySort previewSort) {\n    _updateState((collection) {\n      final selectedSort = switch (collection) {\n        FullCollection _ => sort,\n        PreviewCollection _ => previewSort,\n      };\n\n      if (_sort == selectedSort) return;\n      _sort = selectedSort;\n\n      collection.sort(selectedSort);\n      return null;\n    });\n  }\n\n  void changeIndex(int newIndex) => _updateState(\n    (collection) => switch (collection) {\n      FullCollection _ => collection.withIndex(newIndex),\n      PreviewCollection _ => collection,\n    },\n  );\n\n  void removeEntry(int mediaId) {\n    _updateState(\n      (collection) => switch (collection) {\n        PreviewCollection c => c..list.removeByMediaId(mediaId),\n        FullCollection c => _withRemovedEmptyLists(\n          c..lists.forEach((list) => list.removeByMediaId(mediaId)),\n        ),\n      },\n    );\n  }\n\n  /// There is an api bug in entry updating,\n  /// which prevents tag data from being returned.\n  /// This is why [saveEntry] additionally fetches the updated entry.\n  Future<void> saveEntry(int mediaId, ListStatus? oldStatus) async {\n    try {\n      var data = await ref.read(repositoryProvider).request(GqlQuery.listEntry, {\n        'userId': arg.userId,\n        'mediaId': mediaId,\n      });\n      data = data['MediaList'];\n\n      final entry = Entry(data, ref.read(persistenceProvider).options.imageQuality);\n\n      _updateState(\n        (collection) => switch (collection) {\n          FullCollection _ => _saveEntryInFullCollection(collection, entry, oldStatus, data),\n          PreviewCollection _ => _saveEntryInPreviewCollection(\n            collection,\n            entry,\n            oldStatus,\n            entry.listStatus,\n          ),\n        },\n      );\n    } catch (_) {}\n  }\n\n  /// An alternative to [saveEntry],\n  /// that only updates the progress and potentially, the list status.\n  /// When incrementing to last episode, [saveEntry] should be called instead.\n  Future<String?> saveEntryProgress(Entry oldEntry, bool setAsCurrent) async {\n    try {\n      await ref.read(repositoryProvider).request(GqlMutation.updateProgress, {\n        'mediaId': oldEntry.mediaId,\n        'progress': oldEntry.progress,\n        if (setAsCurrent) ...{\n          'status': ListStatus.current.value,\n          if (oldEntry.watchStart == null) 'startedAt': DateTime.now().fuzzyDate,\n        },\n      });\n\n      await saveEntry(oldEntry.mediaId, oldEntry.listStatus);\n\n      return null;\n    } catch (e) {\n      return e.toString();\n    }\n  }\n\n  FullCollection _saveEntryInFullCollection(\n    FullCollection collection,\n    Entry entry,\n    ListStatus? oldStatus,\n    Map<String, dynamic> data,\n  ) {\n    final hiddenFromStatusLists = data['hiddenFromStatusLists'] ?? false;\n    final customListItems = data['customLists'] ?? const <String, dynamic>{};\n    final customLists = customListItems.entries\n        .where((e) => e.value == true)\n        .map((e) => e.key.toLowerCase())\n        .toList();\n\n    for (final list in collection.lists) {\n      if (list.status != null) {\n        if (list.status == oldStatus) {\n          if (list.status == entry.listStatus) {\n            if (hiddenFromStatusLists) {\n              list.removeByMediaId(entry.mediaId);\n              continue;\n            }\n\n            if (!list.setByMediaId(entry)) {\n              list.insertSorted(entry, _sort);\n            }\n\n            continue;\n          }\n\n          list.removeByMediaId(entry.mediaId);\n          continue;\n        }\n\n        if (list.status == entry.listStatus) {\n          list.insertSorted(entry, _sort);\n        }\n\n        continue;\n      }\n\n      if (customLists.contains(list.name.toLowerCase())) {\n        if (!list.setByMediaId(entry)) {\n          list.insertSorted(entry, _sort);\n        }\n\n        continue;\n      }\n\n      list.removeByMediaId(entry.mediaId);\n    }\n\n    return _withRemovedEmptyLists(collection);\n  }\n\n  PreviewCollection _saveEntryInPreviewCollection(\n    PreviewCollection collection,\n    Entry entry,\n    ListStatus? oldStatus,\n    ListStatus? newStatus,\n  ) {\n    if (newStatus == .current || newStatus == .repeating) {\n      if (oldStatus == .current || oldStatus == .repeating) {\n        collection.list.setByMediaId(entry);\n        return collection;\n      }\n\n      collection.list.insertSorted(entry, _sort);\n      return collection;\n    }\n\n    collection.list.removeByMediaId(entry.mediaId);\n    return collection;\n  }\n\n  FullCollection _withRemovedEmptyLists(FullCollection collection) {\n    final lists = collection.lists;\n    int index = collection.index;\n\n    for (int i = 0; i < lists.length; i++) {\n      if (lists[i].entries.isEmpty) {\n        if (i <= index) index--;\n        lists.removeAt(i--);\n      }\n    }\n\n    return collection.withIndex(index);\n  }\n\n  void _updateState(Collection? Function(Collection) mutator) {\n    if (!state.hasValue) return;\n    final result = mutator(state.value!);\n    if (result != null) state = AsyncValue.data(result);\n  }\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_top_bar.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/collection/collection_entries_provider.dart';\nimport 'package:otraku/feature/collection/collection_filter_provider.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/collection/collection_provider.dart';\nimport 'package:otraku/feature/collection/collection_filter_view.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/debounce.dart';\nimport 'package:otraku/widget/input/search_field.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass CollectionTopBarTrailingContent extends StatelessWidget {\n  const CollectionTopBarTrailingContent(this.tag, this.focusNode);\n\n  final CollectionTag tag;\n  final FocusNode? focusNode;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        final filter = ref.watch(collectionFilterProvider(tag));\n\n        final filterIcon = IconButton(\n          tooltip: 'Filter',\n          icon: const Icon(Ionicons.funnel_outline),\n          onPressed: () => showSheet(\n            context,\n            CollectionFilterView(\n              tag: tag,\n              filter: filter.mediaFilter,\n              onChanged: (mediaFilter) => ref\n                  .read(collectionFilterProvider(tag).notifier)\n                  .update((s) => s.copyWith(mediaFilter: mediaFilter)),\n            ),\n          ),\n        );\n\n        return Expanded(\n          child: Row(\n            children: [\n              Expanded(\n                child: SearchField(\n                  debounce: Debounce(),\n                  focusNode: focusNode,\n                  hint: ref.watch(collectionProvider(tag).select((s) => s.value?.listName ?? '')),\n                  value: filter.search,\n                  onChanged: (search) => ref\n                      .read(collectionFilterProvider(tag).notifier)\n                      .update((s) => s.copyWith(search: search)),\n                ),\n              ),\n              IconButton(\n                tooltip: 'Random',\n                icon: const Icon(Ionicons.shuffle_outline),\n                onPressed: () {\n                  final lists = ref.read(collectionEntriesProvider(tag));\n                  if (lists.isEmpty) {\n                    ConfirmationDialog.show(context, title: 'No entries');\n                    return;\n                  }\n\n                  final list = lists[Random().nextInt(lists.length)];\n                  if (list.entries.isEmpty) {\n                    ConfirmationDialog.show(context, title: 'No entries');\n                    return;\n                  }\n\n                  final entry = list.entries[Random().nextInt(list.entries.length)];\n                  context.push(Routes.media(entry.mediaId, entry.imageUrl));\n                },\n              ),\n              if (filter.mediaFilter.isActive)\n                Badge(\n                  smallSize: 10,\n                  alignment: Alignment.topLeft,\n                  backgroundColor: ColorScheme.of(context).primary,\n                  child: filterIcon,\n                )\n              else\n                filterIcon,\n            ],\n          ),\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/collection/collection_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/collection/collection_floating_action.dart';\nimport 'package:otraku/feature/collection/collection_top_bar.dart';\nimport 'package:otraku/feature/discover/discover_filter_model.dart';\nimport 'package:otraku/feature/discover/discover_filter_provider.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/collection/collection_entries_provider.dart';\nimport 'package:otraku/feature/collection/collection_filter_provider.dart';\nimport 'package:otraku/feature/collection/collection_grid.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/collection/collection_provider.dart';\nimport 'package:otraku/widget/input/pill_selector.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/feature/collection/collection_list.dart';\n\nclass CollectionView extends StatefulWidget {\n  const CollectionView(this.userId, this.ofAnime);\n\n  final int userId;\n  final bool ofAnime;\n\n  @override\n  State<CollectionView> createState() => _CollectionViewState();\n}\n\nclass _CollectionViewState extends State<CollectionView> {\n  final _ctrl = ScrollController();\n\n  @override\n  void dispose() {\n    _ctrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final tag = (userId: widget.userId, ofAnime: widget.ofAnime);\n    final formFactor = Theming.of(context).formFactor;\n\n    return AdaptiveScaffold(\n      topBar: TopBar(trailing: [CollectionTopBarTrailingContent(tag, null)]),\n      floatingAction: formFactor == .phone\n          ? HidingFloatingActionButton(\n              key: const Key('lists'),\n              scrollCtrl: _ctrl,\n              child: CollectionFloatingAction(tag),\n            )\n          : null,\n      child: CollectionSubview(tag: tag, scrollCtrl: _ctrl, formFactor: formFactor),\n    );\n  }\n}\n\nclass CollectionSubview extends StatelessWidget {\n  const CollectionSubview({\n    required this.tag,\n    required this.scrollCtrl,\n    required this.formFactor,\n    super.key,\n  });\n\n  final CollectionTag? tag;\n  final ScrollController scrollCtrl;\n  final FormFactor formFactor;\n\n  @override\n  Widget build(BuildContext context) {\n    if (tag == null) {\n      return const Center(\n        child: Padding(\n          padding: Theming.paddingAll,\n          child: Text('Log in from the profile tab to view your collections', textAlign: .center),\n        ),\n      );\n    }\n\n    return Consumer(\n      builder: (context, ref, _) {\n        ref.listen<AsyncValue>(\n          collectionProvider(tag!),\n          (_, s) =>\n              s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())),\n        );\n\n        return ref\n            .watch(collectionProvider(tag!))\n            .unwrapPrevious()\n            .when(\n              loading: () => const Center(child: Loader()),\n              error: (_, _) => CustomScrollView(\n                physics: Theming.bouncyPhysics,\n                slivers: [\n                  SliverRefreshControl(onRefresh: () => ref.invalidate(collectionProvider(tag!))),\n                  const SliverFillRemaining(child: Center(child: Text('Failed to load'))),\n                ],\n              ),\n              data: (data) {\n                final content = Scrollbar(\n                  controller: scrollCtrl,\n                  child: ConstrainedView(\n                    child: CustomScrollView(\n                      physics: Theming.bouncyPhysics,\n                      controller: scrollCtrl,\n                      slivers: [\n                        SliverRefreshControl(\n                          onRefresh: () => ref.invalidate(collectionProvider(tag!)),\n                        ),\n                        _Content(tag!, data),\n                        const SliverFooter(),\n                      ],\n                    ),\n                  ),\n                );\n\n                if (formFactor == .phone) return content;\n\n                return switch (data) {\n                  PreviewCollection _ => content,\n                  FullCollection c => Row(\n                    children: [\n                      PillSelector(\n                        maxWidth: 200,\n                        selected: c.index + 1,\n                        items: buildFullCollectionSelectionItems(context, data.lists),\n                        onTap: (i) =>\n                            ref.read(collectionProvider(tag!).notifier).changeIndex(i - 1),\n                      ),\n                      Expanded(child: content),\n                    ],\n                  ),\n                };\n              },\n            );\n      },\n    );\n  }\n}\n\nclass _Content extends StatelessWidget {\n  const _Content(this.tag, this.collection);\n\n  final CollectionTag tag;\n  final Collection collection;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        final lists = ref.watch(collectionEntriesProvider(tag));\n        final isViewer = ref.watch(viewerIdProvider) == tag.userId;\n\n        if (lists.isEmpty) {\n          if (!isViewer) {\n            return const SliverFillRemaining(child: Center(child: Text('No results')));\n          }\n\n          return SliverFillRemaining(\n            child: Center(\n              child: Column(\n                mainAxisSize: .min,\n                children: [\n                  const Text('No results'),\n                  TextButton(\n                    onPressed: () => _searchGlobally(context, ref),\n                    child: const Text('Search Globally'),\n                  ),\n                ],\n              ),\n            ),\n          );\n        }\n\n        final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n        final onProgressUpdated = isViewer\n            ? (oldEntry, setAsCurrent) => ref\n                  .read(collectionProvider(tag).notifier)\n                  .saveEntryProgress(oldEntry, setAsCurrent)\n            : null;\n\n        final (collectionIsExpanded, showAllLists) = switch (collection) {\n          PreviewCollection _ => (false, false),\n          FullCollection c => (true, c.index < 0),\n        };\n\n        final useSimpleGrid =\n            collectionIsExpanded && options.collectionItemView == .simple ||\n            !collectionIsExpanded && options.collectionPreviewItemView == .simple;\n\n        if (!showAllLists) {\n          return useSimpleGrid\n              ? CollectionGrid(\n                  items: lists[0].entries,\n                  onProgressUpdated: onProgressUpdated,\n                  highContrast: options.highContrast,\n                )\n              : CollectionList(\n                  items: lists[0].entries,\n                  onProgressUpdated: onProgressUpdated,\n                  scoreFormat: ref.watch(\n                    collectionProvider(tag).select((s) => s.value?.scoreFormat ?? .point10Decimal),\n                  ),\n                  highContrast: options.highContrast,\n                );\n        }\n\n        return SliverMainAxisGroup(\n          slivers: [\n            for (final l in lists) ...[\n              SliverToBoxAdapter(\n                child: Padding(\n                  padding: const .only(bottom: Theming.offset),\n                  child: Text(l.name, style: TextTheme.of(context).bodyLarge),\n                ),\n              ),\n              useSimpleGrid\n                  ? CollectionGrid(\n                      items: l.entries,\n                      onProgressUpdated: onProgressUpdated,\n                      highContrast: options.highContrast,\n                    )\n                  : CollectionList(\n                      items: l.entries,\n                      onProgressUpdated: onProgressUpdated,\n                      scoreFormat: ref.watch(\n                        collectionProvider(\n                          tag,\n                        ).select((s) => s.value?.scoreFormat ?? .point10Decimal),\n                      ),\n                      highContrast: options.highContrast,\n                    ),\n            ],\n          ],\n        );\n      },\n    );\n  }\n\n  void _searchGlobally(BuildContext context, WidgetRef ref) {\n    final collectionFilter = ref.read(collectionFilterProvider(tag));\n    final sort = ref.read(persistenceProvider).discoverMediaFilter.sort;\n\n    ref\n        .read(discoverFilterProvider.notifier)\n        .update(\n          (f) => f.copyWith(\n            type: tag.ofAnime ? .anime : .manga,\n            search: collectionFilter.search,\n            mediaFilter: DiscoverMediaFilter.fromCollection(\n              filter: collectionFilter.mediaFilter,\n              sort: sort,\n              ofAnime: tag.ofAnime,\n            ),\n          ),\n        );\n\n    context.go(Routes.home(.discover));\n    ref.invalidate(collectionFilterProvider(tag));\n  }\n}\n"
  },
  {
    "path": "lib/feature/comment/comment_model.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/util/markdown.dart';\n\nclass Comment {\n  Comment._({\n    required this.id,\n    required this.text,\n    required this.likeCount,\n    required this.isLiked,\n    required this.isLocked,\n    required this.createdAt,\n    required this.siteUrl,\n    required this.userId,\n    required this.userName,\n    required this.userAvatarUrl,\n    required this.threadId,\n    required this.threadTitle,\n    required this.childComments,\n  });\n\n  factory Comment(Map<String, dynamic> map) {\n    final childComments = <Comment>[];\n    for (final c in map['childComments'] ?? const []) {\n      childComments.add(Comment(c));\n    }\n\n    return Comment._(\n      id: map['id'],\n      text: parseMarkdown(map['comment'] ?? ''),\n      likeCount: map['likeCount'] ?? 0,\n      isLiked: map['isLiked'] ?? false,\n      isLocked: map['isLocked'] ?? false,\n      createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),\n      siteUrl: map['siteUrl'] ?? '',\n      userId: map['user']?['id'] ?? 0,\n      userName: map['user']?['name'] ?? '?',\n      userAvatarUrl: map['user']?['avatar']?['large'] ?? '',\n      threadId: map['thread']?['id'] ?? 0,\n      threadTitle: map['thread']?['title'] ?? '',\n      childComments: childComments,\n    );\n  }\n\n  final int id;\n  final String text;\n  int likeCount;\n  bool isLiked;\n  final bool isLocked;\n  final DateTime createdAt;\n  final String siteUrl;\n  final int userId;\n  final String userName;\n  final String userAvatarUrl;\n  final int threadId;\n  final String threadTitle;\n  final List<Comment> childComments;\n\n  Comment _copyWith({String? text, List<Comment>? childComments}) => Comment._(\n    id: id,\n    text: text ?? this.text,\n    likeCount: likeCount,\n    isLiked: isLiked,\n    isLocked: isLocked,\n    createdAt: createdAt,\n    siteUrl: siteUrl,\n    userId: userId,\n    userName: userName,\n    userAvatarUrl: userAvatarUrl,\n    threadId: threadId,\n    threadTitle: threadTitle,\n    childComments: childComments ?? this.childComments,\n  );\n\n  Comment withEditedText(String text) => _copyWith(text: text);\n\n  Comment withAppendedChildComment(Map<String, dynamic> map, int parentCommentId) {\n    if (id == parentCommentId) {\n      return _copyWith(childComments: [...childComments, Comment(map)]);\n    }\n\n    for (final comment in childComments) {\n      if (comment.append(map, parentCommentId)) {\n        return _copyWith(childComments: [...childComments]);\n      }\n    }\n\n    return this;\n  }\n\n  bool append(Map<String, dynamic> map, int parentCommentId) {\n    for (final comment in childComments) {\n      if (comment.id == parentCommentId) {\n        comment.childComments.add(Comment(map));\n        return true;\n      }\n\n      if (comment.append(map, parentCommentId)) {\n        return true;\n      }\n    }\n\n    return false;\n  }\n}\n"
  },
  {
    "path": "lib/feature/comment/comment_provider.dart",
    "content": "import 'dart:async';\nimport 'dart:collection';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/feature/comment/comment_model.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal commentProvider = AsyncNotifierProvider.autoDispose.family<CommentNotifier, Comment, int>(\n  CommentNotifier.new,\n);\n\nclass CommentNotifier extends AsyncNotifier<Comment> {\n  CommentNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Comment> build() async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.comment, {'id': arg});\n\n    // The response is a list of comments that match the filter criteria.\n    // Since we're filtering by id, we expect exactly one comment.\n    final comments = data['ThreadComment'];\n    if (comments.isEmpty) {\n      throw Exception('Not Found');\n    }\n\n    // The response always starts from the root comment,\n    // even if a subcomment was requested.\n    // We search for the requested subcomment with BFS.\n    final queue = Queue<Map<String, dynamic>>();\n    queue.add(comments[0]);\n    while (queue.isNotEmpty) {\n      final comment = queue.removeFirst();\n      if (comment['id'] == arg) {\n        return Comment(comment);\n      }\n\n      for (final child in comment['childComments'] ?? const []) {\n        queue.addLast(child);\n      }\n    }\n\n    throw Exception('Not Found');\n  }\n\n  void edit(Map<String, dynamic> map) =>\n      state = state.whenData((data) => data.withEditedText(map['comment']));\n\n  Future<Object?> toggleCommentLike(int commentId) {\n    return ref.read(repositoryProvider).request(GqlMutation.toggleLike, {\n      'id': commentId,\n      'type': 'THREAD_COMMENT',\n    }).getErrorOrNull();\n  }\n\n  void appendComment(Map<String, dynamic> map, int parentCommentId) {\n    final value = state.value;\n    if (value == null) return;\n\n    state = AsyncValue.data(value.withAppendedChildComment(map, parentCommentId));\n  }\n\n  Future<Object?> delete() =>\n      ref.read(repositoryProvider).request(GqlMutation.deleteComment, {'id': arg}).getErrorOrNull();\n}\n"
  },
  {
    "path": "lib/feature/comment/comment_tile.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/composition/composition_model.dart';\nimport 'package:otraku/feature/composition/composition_view.dart';\nimport 'package:otraku/feature/comment/comment_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/widget/timestamp.dart';\n\nconst _maxCommentDepth = 6;\n\ntypedef CommentTileInteraction = ({\n  void Function(Map<String, dynamic> map, int commentId) onReplySaved,\n  Future<Object?> Function(int commentId) toggleLike,\n});\n\nclass CommentTile extends StatelessWidget {\n  const CommentTile(\n    this.comment, {\n    required this.viewerId,\n    required this.highContrast,\n    required this.analogClock,\n    this.interaction,\n    this.depth = 0,\n  });\n\n  final Comment comment;\n  final CommentTileInteraction? interaction;\n  final int? viewerId;\n  final bool highContrast;\n  final bool analogClock;\n  final int depth;\n\n  @override\n  Widget build(BuildContext context) {\n    final userRow = Row(\n      spacing: Theming.offset,\n      children: [\n        GestureDetector(\n          onTap: () => context.push(Routes.user(comment.userId, comment.userAvatarUrl)),\n          child: ClipRRect(\n            borderRadius: Theming.borderRadiusSmall,\n            child: CachedImage(comment.userAvatarUrl, height: 50, width: 50),\n          ),\n        ),\n        Expanded(\n          child: OverflowBar(\n            spacing: 5,\n            overflowSpacing: 5,\n            children: [\n              Text(comment.userName, overflow: .ellipsis, maxLines: 1),\n              Timestamp(\n                comment.createdAt,\n                analogClock,\n                leading: Text('replied', style: TextTheme.of(context).labelSmall),\n              ),\n            ],\n          ),\n        ),\n      ],\n    );\n\n    final contentColumn = Padding(\n      padding: const .only(left: Theming.offset, top: Theming.offset),\n      child: Column(\n        mainAxisSize: .min,\n        crossAxisAlignment: .start,\n        children: [\n          Padding(padding: const .only(right: 10, bottom: 5), child: HtmlContent(comment.text)),\n          Padding(\n            padding: const .only(right: 10, bottom: 10),\n            child: Row(\n              spacing: Theming.offset,\n              children: [\n                if (comment.isLocked)\n                  Tooltip(\n                    message: 'Locked',\n                    triggerMode: .tap,\n                    child: Icon(Icons.lock_outline_rounded, size: Theming.iconSmall),\n                  ),\n                const Spacer(),\n                if (interaction != null) ...[\n                  if (comment.userId != viewerId)\n                    Tooltip(\n                      message: 'Reply',\n                      child: InkResponse(\n                        radius: Theming.radiusSmall.x,\n                        onTap: () => showSheet(\n                          context,\n                          CompositionView(\n                            tag: CommentCompositionTag(\n                              threadId: comment.threadId,\n                              parentCommentId: comment.id,\n                            ),\n                            onSaved: (map) => interaction!.onReplySaved(map, comment.id),\n                          ),\n                        ),\n                        child: Row(\n                          spacing: 5,\n                          children: [\n                            Text(\n                              comment.childComments.length.toString(),\n                              style: TextTheme.of(context).labelSmall,\n                            ),\n                            const Icon(Icons.reply_all_rounded, size: Theming.iconSmall),\n                          ],\n                        ),\n                      ),\n                    )\n                  else\n                    Tooltip(\n                      message: 'Replies',\n                      child: InkResponse(\n                        radius: Theming.radiusSmall.x,\n                        onTap: () => context.push(Routes.comment(comment.id)),\n                        child: Row(\n                          spacing: 5,\n                          children: [\n                            Text(\n                              comment.childComments.length.toString(),\n                              style: TextTheme.of(context).labelSmall,\n                            ),\n                            const Icon(Icons.reply_all_rounded, size: Theming.iconSmall),\n                          ],\n                        ),\n                      ),\n                    ),\n                  _LikeButton(comment, interaction!.toggleLike),\n                ] else ...[\n                  SizedBox(\n                    height: 20,\n                    child: Tooltip(\n                      message: 'Replies',\n                      child: InkResponse(\n                        radius: Theming.radiusSmall.x,\n                        onTap: () => context.push(Routes.comment(comment.id)),\n                        child: const Icon(Icons.reply_all_rounded, size: Theming.iconSmall),\n                      ),\n                    ),\n                  ),\n                  Tooltip(\n                    message: 'Likes',\n                    triggerMode: .tap,\n                    child: Row(\n                      mainAxisSize: .min,\n                      children: [\n                        Text(\n                          comment.likeCount.toString(),\n                          style: Theme.of(context).textTheme.labelSmall,\n                        ),\n                        const SizedBox(width: 5),\n                        Icon(Icons.favorite_outline_rounded, size: Theming.iconSmall),\n                      ],\n                    ),\n                  ),\n                ],\n              ],\n            ),\n          ),\n          if (comment.childComments.isNotEmpty)\n            depth < _maxCommentDepth\n                ? Column(\n                    spacing: Theming.offset,\n                    mainAxisSize: .min,\n                    children: comment.childComments\n                        .map(\n                          (c) => CommentTile(\n                            c,\n                            viewerId: viewerId,\n                            highContrast: highContrast,\n                            analogClock: analogClock,\n                            interaction: interaction,\n                            depth: depth + 1,\n                          ),\n                        )\n                        .toList(),\n                  )\n                : TextButton(\n                    onPressed: () => context.push(Routes.comment(comment.id)),\n                    child: Text(\n                      comment.childComments.length > 1\n                          ? '${comment.childComments.length} replies'\n                          : '1 reply',\n                    ),\n                  ),\n        ],\n      ),\n    );\n\n    return Column(\n      spacing: Theming.offset,\n      mainAxisSize: .min,\n      crossAxisAlignment: .start,\n      children: [\n        userRow,\n        if (highContrast)\n          Card.outlined(child: contentColumn)\n        else\n          Card(\n            color: depth % 2 == 0 ? null : ColorScheme.of(context).surfaceContainerHigh,\n            child: contentColumn,\n          ),\n      ],\n    );\n  }\n}\n\nclass _LikeButton extends StatefulWidget {\n  const _LikeButton(this.comment, this.toggleLike);\n\n  final Comment comment;\n  final Future<Object?> Function(int commentId) toggleLike;\n\n  @override\n  State<_LikeButton> createState() => __LikeButtonState();\n}\n\nclass __LikeButtonState extends State<_LikeButton> {\n  @override\n  Widget build(BuildContext context) {\n    final comment = widget.comment;\n\n    return Tooltip(\n      message: !comment.isLiked ? 'Like' : 'Unlike',\n      child: InkResponse(\n        radius: Theming.radiusSmall.x,\n        onTap: () async {\n          final prevIsLiked = comment.isLiked;\n          final prevLikeCount = comment.likeCount;\n\n          setState(() {\n            comment.isLiked = !prevIsLiked;\n            comment.likeCount = prevLikeCount + 1;\n          });\n\n          final err = await widget.toggleLike(comment.id);\n          if (err == null) return;\n\n          setState(() {\n            comment.isLiked = prevIsLiked;\n            comment.likeCount = prevLikeCount;\n          });\n\n          if (context.mounted) {\n            SnackBarExtension.show(context, err.toString());\n          }\n        },\n        child: Row(\n          children: [\n            Text(\n              comment.likeCount.toString(),\n              style: !comment.isLiked\n                  ? TextTheme.of(context).labelSmall\n                  : TextTheme.of(\n                      context,\n                    ).labelSmall!.copyWith(color: ColorScheme.of(context).primary),\n            ),\n            const SizedBox(width: 5),\n            Icon(\n              !comment.isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded,\n              size: Theming.iconSmall,\n              color: comment.isLiked ? ColorScheme.of(context).primary : null,\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/comment/comment_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/comment/comment_model.dart';\nimport 'package:otraku/feature/comment/comment_provider.dart';\nimport 'package:otraku/feature/comment/comment_tile.dart';\nimport 'package:otraku/feature/composition/composition_model.dart';\nimport 'package:otraku/feature/composition/composition_view.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass CommentView extends ConsumerStatefulWidget {\n  const CommentView(this.id);\n\n  final int id;\n\n  @override\n  ConsumerState<CommentView> createState() => _CommentViewState();\n}\n\nclass _CommentViewState extends ConsumerState<CommentView> {\n  final _scrollCtrl = ScrollController();\n\n  @override\n  void dispose() {\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    ref.listen<AsyncValue>(\n      commentProvider(widget.id),\n      (_, s) =>\n          s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())),\n    );\n\n    final comment = ref.watch(commentProvider(widget.id));\n    final viewerId = ref.watch(viewerIdProvider);\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    TopBar? topBar;\n    void Function()? floatingActionOnPressed;\n\n    if (comment.hasValue) {\n      final value = comment.value!;\n\n      topBar = TopBar(trailing: _topBarTrailingContent(context, ref, value, viewerId));\n\n      floatingActionOnPressed = () => showSheet(\n        context,\n        CompositionView(\n          tag: CommentCompositionTag(threadId: value.threadId, parentCommentId: value.id),\n          onSaved: (map) =>\n              ref.read(commentProvider(widget.id).notifier).appendComment(map, value.id),\n        ),\n      );\n    }\n\n    return AdaptiveScaffold(\n      topBar: topBar ?? const TopBar(),\n      floatingAction: HidingFloatingActionButton(\n        key: const Key('Reply'),\n        scrollCtrl: _scrollCtrl,\n        child: FloatingActionButton(\n          tooltip: 'New Reply',\n          onPressed: floatingActionOnPressed,\n          child: const Icon(Icons.edit_outlined),\n        ),\n      ),\n      child: ConstrainedView(\n        child: switch (comment.unwrapPrevious()) {\n          AsyncData(:final value) => _Content(\n            ref,\n            value,\n            options.highContrast,\n            options.analogClock,\n          ),\n          AsyncError() => CustomScrollView(\n            physics: Theming.bouncyPhysics,\n            slivers: [\n              SliverRefreshControl(onRefresh: () => ref.invalidate(commentProvider(widget.id))),\n              const SliverFillRemaining(child: Center(child: Text('Failed to load'))),\n            ],\n          ),\n          AsyncLoading() => const Center(child: Loader()),\n        },\n      ),\n    );\n  }\n\n  List<Widget> _topBarTrailingContent(\n    BuildContext context,\n    WidgetRef ref,\n    Comment comment,\n    int? viewerId,\n  ) => [\n    const Spacer(),\n    IconButton(\n      tooltip: 'More',\n      icon: const Icon(Ionicons.ellipsis_horizontal),\n      onPressed: () => showSheet(\n        context,\n        SimpleSheet.link(\n          context,\n          comment.siteUrl,\n          viewerId == comment.userId\n              ? [\n                  ListTile(\n                    title: const Text('Edit'),\n                    leading: const Icon(Icons.edit_outlined),\n                    onTap: () => showSheet(\n                      context,\n                      CompositionView(\n                        tag: CommentCompositionTag.edit(id: comment.id, threadId: comment.threadId),\n                        onSaved: (map) {\n                          ref.read(commentProvider(widget.id).notifier).edit(map);\n\n                          Navigator.pop(context);\n                        },\n                      ),\n                    ),\n                  ),\n                  ListTile(\n                    title: const Text('Delete'),\n                    leading: const Icon(Ionicons.trash_outline),\n                    onTap: () {\n                      Navigator.pop(context);\n\n                      ConfirmationDialog.show(\n                        context,\n                        title: 'Delete?',\n                        primaryAction: 'Yes',\n                        secondaryAction: 'No',\n                        onConfirm: () async {\n                          final err = await ref.read(commentProvider(widget.id).notifier).delete();\n\n                          if (!context.mounted) return;\n\n                          if (err == null) {\n                            Navigator.pop(context);\n                            return;\n                          }\n\n                          SnackBarExtension.show(context, 'Failed deleting comment: $err');\n                        },\n                      );\n                    },\n                  ),\n                ]\n              : const [],\n        ),\n      ),\n    ),\n  ];\n}\n\nclass _Content extends StatelessWidget {\n  const _Content(this.ref, this.comment, this.highContrast, this.analogClock);\n\n  final WidgetRef ref;\n  final Comment comment;\n  final bool highContrast;\n  final bool analogClock;\n\n  @override\n  Widget build(BuildContext context) {\n    final openThread = () => context.push(Routes.thread(comment.threadId));\n\n    return CustomScrollView(\n      physics: Theming.bouncyPhysics,\n      slivers: [\n        SliverRefreshControl(onRefresh: () => ref.invalidate(commentProvider(comment.id))),\n        SliverToBoxAdapter(\n          child: Semantics(\n            onTap: openThread,\n            onTapHint: 'open thread',\n            child: GestureDetector(\n              onTap: openThread,\n              behavior: .opaque,\n              child: Text(comment.threadTitle, style: TextTheme.of(context).bodyMedium),\n            ),\n          ),\n        ),\n        SliverToBoxAdapter(child: SizedBox(height: Theming.offset)),\n        SliverToBoxAdapter(\n          child: CommentTile(\n            comment,\n            viewerId: ref.watch(viewerIdProvider),\n            highContrast: highContrast,\n            analogClock: analogClock,\n            interaction: (\n              onReplySaved: (map, commentId) =>\n                  ref.read(commentProvider(comment.id).notifier).appendComment(map, commentId),\n              toggleLike: (commentId) =>\n                  ref.read(commentProvider(comment.id).notifier).toggleCommentLike(commentId),\n            ),\n          ),\n        ),\n        const SliverFooter(),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/composition/composition_model.dart",
    "content": "/// Each type of composition is represented by a different tag class that\n/// extends [CompositionTag]. All tags must implement equals and hash for\n/// riverpod to work correctly.\nsealed class CompositionTag {\n  const CompositionTag({required this.id});\n\n  final int? id;\n}\n\nclass StatusActivityCompositionTag extends CompositionTag {\n  const StatusActivityCompositionTag({required super.id});\n\n  @override\n  bool operator ==(Object other) => other is StatusActivityCompositionTag && id == other.id;\n\n  @override\n  int get hashCode => id.hashCode;\n}\n\nclass MessageActivityCompositionTag extends CompositionTag {\n  const MessageActivityCompositionTag({required super.id, required this.recipientId});\n\n  final int recipientId;\n\n  @override\n  bool operator ==(Object other) =>\n      other is MessageActivityCompositionTag && id == other.id && recipientId == other.recipientId;\n\n  @override\n  int get hashCode => Object.hash(id, recipientId);\n}\n\nclass ActivityReplyCompositionTag extends CompositionTag {\n  const ActivityReplyCompositionTag({required super.id, required this.activityId});\n\n  final int activityId;\n\n  @override\n  bool operator ==(Object other) =>\n      other is ActivityReplyCompositionTag && id == other.id && activityId == other.activityId;\n\n  @override\n  int get hashCode => Object.hash(id, activityId);\n}\n\nclass CommentCompositionTag extends CompositionTag {\n  const CommentCompositionTag({required this.threadId, required this.parentCommentId})\n    : super(id: null);\n\n  const CommentCompositionTag.edit({required super.id, required this.threadId})\n    : parentCommentId = null;\n\n  final int threadId;\n  final int? parentCommentId;\n\n  @override\n  bool operator ==(Object other) =>\n      other is CommentCompositionTag &&\n      id == other.id &&\n      threadId == other.threadId &&\n      parentCommentId == other.parentCommentId;\n\n  @override\n  int get hashCode => Object.hash(id, threadId, parentCommentId);\n}\n\nclass Composition {\n  Composition(this.text);\n\n  String text;\n}\n\n/// Only used for new message activities, since the user can toggle visibility.\nclass PrivateComposition extends Composition {\n  PrivateComposition(super.text, this.isPrivate);\n\n  bool isPrivate;\n}\n"
  },
  {
    "path": "lib/feature/composition/composition_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/util/graphql.dart';\nimport 'package:otraku/feature/composition/composition_model.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\n\nfinal compositionProvider = AsyncNotifierProvider.autoDispose\n    .family<CompositionNotifier, Composition, CompositionTag>(CompositionNotifier.new);\n\nclass CompositionNotifier extends AsyncNotifier<Composition> {\n  CompositionNotifier(this.arg);\n\n  final CompositionTag arg;\n\n  @override\n  FutureOr<Composition> build() {\n    if (arg.id == null) {\n      return switch (arg) {\n        MessageActivityCompositionTag _ => PrivateComposition('', false),\n        _ => Composition(''),\n      };\n    }\n\n    return switch (arg) {\n      StatusActivityCompositionTag(id: var id) =>\n        ref\n            .read(repositoryProvider)\n            .request(GqlQuery.activityComposition, {'id': id})\n            .then((data) => Composition(data['Activity']['text'])),\n      MessageActivityCompositionTag(id: var id) =>\n        ref\n            .read(repositoryProvider)\n            .request(GqlQuery.activityComposition, {'id': id})\n            .then((data) => Composition(data['Activity']['message'])),\n      ActivityReplyCompositionTag(id: var id) =>\n        ref\n            .read(repositoryProvider)\n            .request(GqlQuery.activityReplyComposition, {'id': id})\n            .then((data) => Composition(data['ActivityReply']['text'])),\n      CommentCompositionTag(id: var id) =>\n        ref\n            .read(repositoryProvider)\n            .request(GqlQuery.commentComposition, {'id': id})\n            .then((data) => Composition(_findComment(data['ThreadComment'][0]))),\n    };\n  }\n\n  /// The API always returns the root comment,\n  /// so we search for the target comment with DFS.\n  String _findComment(Map<String, dynamic> map) {\n    if (map['id'] == arg.id) {\n      return map['comment'] ?? '';\n    }\n\n    for (final c in map['childComments'] ?? const []) {\n      final comment = _findComment(c);\n      if (comment != '') return comment;\n    }\n\n    return '';\n  }\n\n  Future<AsyncValue<Map<String, dynamic>>> save() async {\n    final value = state.value;\n    if (value == null) return const AsyncValue.loading();\n\n    return AsyncValue.guard(() async {\n      switch (arg) {\n        case StatusActivityCompositionTag(id: var id):\n          final data = await ref.read(repositoryProvider).request(GqlMutation.saveStatusActivity, {\n            'id': ?id,\n            'text': value.text.withParsedEmojis,\n          });\n          return data['SaveTextActivity'];\n        case MessageActivityCompositionTag(id: var id, recipientId: var rcpId):\n          final data = await ref.read(repositoryProvider).request(GqlMutation.saveMessageActivity, {\n            'id': ?id,\n            'text': value.text.withParsedEmojis,\n            'recipientId': rcpId,\n            if (value is PrivateComposition) 'isPrivate': value.isPrivate,\n          });\n          return data['SaveMessageActivity'];\n        case ActivityReplyCompositionTag(id: var id, activityId: var actId):\n          final data = await ref.read(repositoryProvider).request(GqlMutation.saveActivityReply, {\n            'id': ?id,\n            'text': value.text.withParsedEmojis,\n            'activityId': actId,\n          });\n          return data['SaveActivityReply'];\n        case CommentCompositionTag(\n          id: var id,\n          threadId: var threadId,\n          parentCommentId: var parentCommentId,\n        ):\n          final data = await ref.read(repositoryProvider).request(GqlMutation.saveComment, {\n            'id': ?id,\n            'text': value.text.withParsedEmojis,\n            'threadId': threadId,\n            'parentCommentId': ?parentCommentId,\n          });\n          return data['SaveThreadComment'];\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "lib/feature/composition/composition_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/util/markdown.dart';\nimport 'package:otraku/feature/composition/composition_model.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:otraku/widget/layout/navigation_tool.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/composition/composition_provider.dart';\n\nclass CompositionView extends StatelessWidget {\n  const CompositionView({required this.tag, required this.onSaved, this.defaultText = ''});\n\n  final CompositionTag tag;\n  final String defaultText;\n\n  /// When the edit is saved, a map with the new data is passed back\n  /// to get deserialized.\n  final void Function(Map<String, dynamic>) onSaved;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        return ref\n            .watch(compositionProvider(tag))\n            .when(\n              loading: () => SheetWithButtonRow(\n                builder: (context, scrollCtrl) => const Center(child: Loader()),\n              ),\n              error: (_, _) => SheetWithButtonRow(\n                builder: (context, scrollCtrl) => const Center(child: Text('Failed Loading')),\n              ),\n              data: (data) {\n                if (data.text.isEmpty) {\n                  data.text = defaultText;\n                }\n\n                return _CompositionView(\n                  composition: data,\n                  trySave: () async {\n                    final result = await ref.read(compositionProvider(tag).notifier).save();\n\n                    return result.maybeWhen(\n                      data: (data) {\n                        onSaved(result.value!);\n                        Navigator.pop(context);\n                        return true;\n                      },\n                      orElse: () => false,\n                    );\n                  },\n                );\n              },\n            );\n      },\n    );\n  }\n}\n\nclass _CompositionView extends StatefulWidget {\n  const _CompositionView({required this.composition, required this.trySave});\n\n  final Composition composition;\n  final Future<bool> Function() trySave;\n\n  @override\n  State<_CompositionView> createState() => __CompositionViewState();\n}\n\nclass __CompositionViewState extends State<_CompositionView> with SingleTickerProviderStateMixin {\n  late final _textCtrl = TextEditingController(text: widget.composition.text);\n  late final _tabCtrl = TabController(length: 2, vsync: this);\n  String _parsedText = '';\n  final _focus = FocusNode();\n\n  @override\n  void initState() {\n    super.initState();\n    _tabCtrl.addListener(() {\n      setState(() {});\n      if (_tabCtrl.index == 0) {\n        _focus.requestFocus();\n      } else {\n        _focus.unfocus();\n        _parsedText = parseMarkdown(_textCtrl.text);\n      }\n    });\n  }\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    _textCtrl.dispose();\n    _focus.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return SheetWithButtonRow(\n      builder: (context, scrollCtrl) => _CompositionBody(\n        focus: _focus,\n        tabCtrl: _tabCtrl,\n        textCtrl: _textCtrl,\n        scrollCtrl: scrollCtrl,\n        parsedText: _parsedText,\n      ),\n      buttons: _BottomBar(\n        composition: widget.composition,\n        textCtrl: _textCtrl,\n        isEditing: _tabCtrl.index == 0,\n        trySave: widget.trySave,\n      ),\n    );\n  }\n}\n\nclass _CompositionBody extends StatelessWidget {\n  const _CompositionBody({\n    required this.focus,\n    required this.tabCtrl,\n    required this.textCtrl,\n    required this.scrollCtrl,\n    required this.parsedText,\n  });\n\n  final FocusNode focus;\n  final TabController tabCtrl;\n  final TextEditingController textCtrl;\n  final ScrollController scrollCtrl;\n  final String parsedText;\n\n  @override\n  Widget build(BuildContext context) {\n    final padding = EdgeInsets.only(\n      left: 20,\n      right: 20,\n      top: 60,\n      bottom: MediaQuery.paddingOf(context).bottom + Theming.offset,\n    );\n\n    return Stack(\n      children: [\n        TabBarView(\n          controller: tabCtrl,\n          children: [\n            SingleChildScrollView(\n              controller: scrollCtrl,\n              child: TextField(\n                autofocus: true,\n                focusNode: focus,\n                controller: textCtrl,\n                style: TextTheme.of(context).bodyMedium,\n                decoration: InputDecoration(contentPadding: padding),\n                maxLines: null,\n              ),\n            ),\n            SingleChildScrollView(\n              controller: scrollCtrl,\n              child: Padding(padding: padding, child: HtmlContent(parsedText)),\n            ),\n          ],\n        ),\n        Positioned(\n          top: 0,\n          left: 0,\n          right: 0,\n          child: ClipRRect(\n            borderRadius: const BorderRadius.vertical(top: Theming.radiusBig),\n            child: BackdropFilter(\n              filter: Theming.blurFilter,\n              child: Container(\n                padding: Theming.paddingAll,\n                color: Theme.of(context).navigationBarTheme.backgroundColor,\n                child: SegmentedButton(\n                  segments: const [\n                    ButtonSegment(\n                      value: 0,\n                      label: Text('Compose'),\n                      icon: Icon(Icons.edit_outlined),\n                    ),\n                    ButtonSegment(\n                      value: 1,\n                      label: Text('Preview'),\n                      icon: Icon(Icons.preview_outlined),\n                    ),\n                  ],\n                  selected: {tabCtrl.index},\n                  onSelectionChanged: (i) => tabCtrl.index = i.first,\n                ),\n              ),\n            ),\n          ),\n        ),\n      ],\n    );\n  }\n}\n\n/// A button menu. Some of the buttons are hidden,\n/// when the user isn't on the editing tab.\nclass _BottomBar extends StatefulWidget {\n  const _BottomBar({\n    required this.composition,\n    required this.isEditing,\n    required this.textCtrl,\n    required this.trySave,\n  });\n\n  final Composition composition;\n  final bool isEditing;\n  final TextEditingController textCtrl;\n  final Future<bool> Function() trySave;\n\n  @override\n  State<_BottomBar> createState() => _BottomBarState();\n}\n\nclass _BottomBarState extends State<_BottomBar> {\n  bool _locked = false;\n\n  @override\n  Widget build(BuildContext context) {\n    return BottomBar([\n      if (widget.isEditing) ...[\n        Expanded(\n          child: ListView(\n            scrollDirection: Axis.horizontal,\n            children: [\n              _FormatButton(\n                startDelimiter: '**',\n                endDelimiter: '**',\n                name: 'Bold',\n                icon: Icons.format_bold_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: '*',\n                endDelimiter: '*',\n                name: 'Italic',\n                icon: Icons.format_italic_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: '~~',\n                endDelimiter: '~~',\n                name: 'Strikethrough',\n                icon: Icons.format_strikethrough_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: '~!',\n                endDelimiter: '!~',\n                name: 'Spoiler',\n                icon: Icons.hide_image_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: '[',\n                endDelimiter: ']()',\n                name: 'Link',\n                icon: Icons.link_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: 'img(',\n                endDelimiter: ')',\n                name: 'Image',\n                icon: Icons.image_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: 'youtube(',\n                endDelimiter: ')',\n                name: 'YouTube Video',\n                icon: Icons.video_collection_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: 'webm(',\n                endDelimiter: ')',\n                name: 'WebM Video',\n                icon: Icons.videocam_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: '~~~',\n                endDelimiter: '~~~',\n                name: 'Center',\n                icon: Icons.align_horizontal_center_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: '# ',\n                endDelimiter: '',\n                name: 'Header',\n                icon: Icons.title_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: '> ',\n                endDelimiter: '',\n                name: 'Quote',\n                icon: Icons.format_quote_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: '`',\n                endDelimiter: '`',\n                name: 'Code',\n                icon: Icons.code_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n              _FormatButton(\n                startDelimiter: '```',\n                endDelimiter: '```',\n                name: 'Code Block',\n                icon: Icons.code_off_outlined,\n                textCtrl: widget.textCtrl,\n              ),\n            ],\n          ),\n        ),\n        Container(\n          width: 3,\n          height: 40,\n          decoration: BoxDecoration(\n            borderRadius: BorderRadius.circular(5),\n            color: ColorScheme.of(context).outline,\n          ),\n        ),\n      ] else\n        const Spacer(),\n      if (widget.composition is PrivateComposition)\n        _PrivateButton(widget.composition as PrivateComposition),\n      IconButton(\n        tooltip: 'Post',\n        icon: const Icon(Ionicons.send_outline),\n        onPressed: _locked\n            ? null\n            : () async {\n                setState(() => _locked = true);\n                widget.composition.text = widget.textCtrl.text;\n                if (await widget.trySave()) return;\n\n                setState(() => _locked = false);\n                if (context.mounted) {\n                  SnackBarExtension.show(context, 'Failed to save');\n                }\n              },\n      ),\n    ]);\n  }\n}\n\n/// Encloses the current text selection in a given markdown tag.\nclass _FormatButton extends StatelessWidget {\n  const _FormatButton({\n    required this.startDelimiter,\n    required this.endDelimiter,\n    required this.name,\n    required this.icon,\n    required this.textCtrl,\n  });\n\n  final String startDelimiter;\n  final String endDelimiter;\n  final String name;\n  final IconData icon;\n  final TextEditingController textCtrl;\n\n  @override\n  Widget build(BuildContext context) => IconButton(\n    tooltip: name,\n    icon: Icon(icon),\n    onPressed: () {\n      final txt = textCtrl.text;\n      final beg = textCtrl.selection.start;\n      final end = textCtrl.selection.end;\n      if (beg < 0) return;\n      final text =\n          '${txt.substring(0, beg)}'\n          '$startDelimiter'\n          '${txt.substring(beg, end)}'\n          '$endDelimiter'\n          '${txt.substring(end)}';\n\n      final offset = textCtrl.selection.isCollapsed\n          ? textCtrl.selection.end + startDelimiter.length\n          : textCtrl.selection.end + startDelimiter.length + endDelimiter.length;\n      textCtrl.value = TextEditingValue(\n        text: text,\n        selection: TextSelection.collapsed(offset: offset),\n      );\n    },\n  );\n}\n\n/// Controls whether a message will be created as private or public.\nclass _PrivateButton extends StatefulWidget {\n  const _PrivateButton(this.composition);\n\n  final PrivateComposition composition;\n\n  @override\n  State<_PrivateButton> createState() => __PrivateButtonState();\n}\n\nclass __PrivateButtonState extends State<_PrivateButton> {\n  @override\n  Widget build(BuildContext context) => IconButton(\n    tooltip: widget.composition.isPrivate ? 'Make Public' : 'Make Private',\n    icon: widget.composition.isPrivate\n        ? const Icon(Ionicons.eye_outline)\n        : const Icon(Ionicons.eye_off_outline),\n    onPressed: () {\n      setState(() => widget.composition.isPrivate = !widget.composition.isPrivate);\n\n      SnackBarExtension.show(\n        context,\n        widget.composition.isPrivate ? 'Message is now private' : 'Message is now public',\n      );\n    },\n  );\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_filter_model.dart",
    "content": "import 'package:otraku/extension/enum_extension.dart';\nimport 'package:otraku/feature/collection/collection_filter_model.dart';\nimport 'package:otraku/feature/discover/discover_model.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/review/review_models.dart';\n\nclass DiscoverFilter {\n  const DiscoverFilter._({\n    required this.type,\n    required this.search,\n    required this.mediaFilter,\n    required this.hasBirthday,\n    required this.reviewsFilter,\n    required this.recommendationsFilter,\n  });\n\n  DiscoverFilter(this.type, this.mediaFilter)\n    : search = '',\n      hasBirthday = false,\n      reviewsFilter = const ReviewsFilter(),\n      recommendationsFilter = const DiscoverRecommendationsFilter();\n\n  final DiscoverType type;\n  final String search;\n  final DiscoverMediaFilter mediaFilter;\n  final bool hasBirthday;\n  final ReviewsFilter reviewsFilter;\n  final DiscoverRecommendationsFilter recommendationsFilter;\n\n  DiscoverFilter copyWith({\n    DiscoverType? type,\n    String? search,\n    DiscoverMediaFilter? mediaFilter,\n    bool? hasBirthday,\n    ReviewsFilter? reviewsFilter,\n    DiscoverRecommendationsFilter? recommendationsFilter,\n  }) => DiscoverFilter._(\n    type: type ?? this.type,\n    search: search ?? this.search,\n    mediaFilter: mediaFilter ?? this.mediaFilter,\n    hasBirthday: hasBirthday ?? this.hasBirthday,\n    reviewsFilter: reviewsFilter ?? this.reviewsFilter,\n    recommendationsFilter: recommendationsFilter ?? this.recommendationsFilter,\n  );\n}\n\nclass DiscoverMediaFilter {\n  DiscoverMediaFilter(this.sort);\n\n  factory DiscoverMediaFilter.fromPersistenceMap(Map<dynamic, dynamic> map) {\n    final sort = MediaSort.values.getOrFirst(map['sort']);\n\n    final filter = DiscoverMediaFilter(sort)\n      ..season = MediaSeason.values.getOrNull(map['season'])\n      ..startYearFrom = map['startYearFrom']\n      ..startYearTo = map['startYearTo']\n      ..country = OriginCountry.values.getOrNull(map['country'])\n      ..inLists = map['inLists']\n      ..isAdult = map['isAdult']\n      ..isLicensed = map['isLicensed'];\n\n    for (final e in map['statuses'] ?? const []) {\n      final status = ReleaseStatus.values.getOrNull(e);\n      if (status != null) {\n        filter.statuses.add(status);\n      }\n    }\n\n    for (final e in map['animeFormats'] ?? const []) {\n      final format = MediaFormat.values.getOrNull(e);\n      if (format != null) {\n        filter.animeFormats.add(format);\n      }\n    }\n\n    for (final e in map['mangaFormats'] ?? const []) {\n      final format = MediaFormat.values.getOrNull(e);\n      if (format != null) {\n        filter.mangaFormats.add(format);\n      }\n    }\n\n    for (final e in map['sources'] ?? const []) {\n      final source = MediaSource.values.getOrNull(e);\n      if (source != null) {\n        filter.sources.add(source);\n      }\n    }\n\n    filter.genreIn.addAll(map['genreIn'] ?? const []);\n    filter.genreNotIn.addAll(map['genreNotIn'] ?? const []);\n    filter.tagIn.addAll(map['tagIn'] ?? const []);\n    filter.tagNotIn.addAll(map['tagNotIn'] ?? const []);\n\n    return filter;\n  }\n\n  final statuses = <ReleaseStatus>[];\n  final animeFormats = <MediaFormat>[];\n  final mangaFormats = <MediaFormat>[];\n  final genreIn = <String>[];\n  final genreNotIn = <String>[];\n  final tagIn = <String>[];\n  final tagNotIn = <String>[];\n  final sources = <MediaSource>[];\n  MediaSort sort;\n  MediaSeason? season;\n  int? startYearFrom;\n  int? startYearTo;\n  OriginCountry? country;\n  bool? inLists;\n  bool? isAdult;\n  bool? isLicensed;\n\n  bool get isActive =>\n      statuses.isNotEmpty ||\n      animeFormats.isNotEmpty ||\n      mangaFormats.isNotEmpty ||\n      genreIn.isNotEmpty ||\n      genreNotIn.isNotEmpty ||\n      tagIn.isNotEmpty ||\n      tagNotIn.isNotEmpty ||\n      sources.isNotEmpty ||\n      season != null ||\n      startYearFrom != null ||\n      startYearTo != null ||\n      country != null ||\n      inLists != null ||\n      isAdult != null ||\n      isLicensed != null;\n\n  DiscoverMediaFilter copy() => DiscoverMediaFilter(sort)\n    ..statuses.addAll(statuses)\n    ..animeFormats.addAll(animeFormats)\n    ..mangaFormats.addAll(mangaFormats)\n    ..genreIn.addAll(genreIn)\n    ..genreNotIn.addAll(genreNotIn)\n    ..tagIn.addAll(tagIn)\n    ..tagNotIn.addAll(tagNotIn)\n    ..sources.addAll(sources)\n    ..season = season\n    ..startYearFrom = startYearFrom\n    ..startYearTo = startYearTo\n    ..country = country\n    ..inLists = inLists\n    ..isAdult = isAdult\n    ..isLicensed = isLicensed;\n\n  static DiscoverMediaFilter fromCollection({\n    required CollectionMediaFilter filter,\n    required MediaSort sort,\n    required bool ofAnime,\n  }) => DiscoverMediaFilter(sort)\n    ..statuses.addAll(filter.statuses)\n    ..animeFormats.addAll(ofAnime ? filter.formats : const [])\n    ..mangaFormats.addAll(!ofAnime ? filter.formats : const [])\n    ..genreIn.addAll(filter.genreIn)\n    ..genreNotIn.addAll(filter.genreNotIn)\n    ..tagIn.addAll(filter.tagIn)\n    ..tagNotIn.addAll(filter.tagNotIn)\n    ..startYearFrom = filter.startYearFrom\n    ..startYearTo = filter.startYearTo\n    ..country = filter.country;\n\n  Map<String, dynamic> toGraphQlVariables({required bool ofAnime}) => {\n    'sort': sort.value,\n    if (ofAnime && animeFormats.isNotEmpty) 'format_in': animeFormats.map((v) => v.value).toList(),\n    if (!ofAnime && mangaFormats.isNotEmpty) 'format_in': mangaFormats.map((v) => v.value).toList(),\n    if (statuses.isNotEmpty) 'status_in': statuses.map((v) => v.value).toList(),\n    if (sources.isNotEmpty) 'sources': sources.map((v) => v.value).toList(),\n    if (ofAnime && season != null) 'season': season!.value,\n    if (genreIn.isNotEmpty) 'genre_in': genreIn,\n    if (genreNotIn.isNotEmpty) 'genre_not_in': genreNotIn,\n    if (tagIn.isNotEmpty) 'tag_in': tagIn,\n    if (tagNotIn.isNotEmpty) 'tag_not_in': tagNotIn,\n    if (startYearFrom != null) 'startFrom': '${startYearFrom! - 1}9999',\n    if (startYearTo != null) 'startTo': '${startYearTo! + 1}0000',\n    if (country != null) 'countryOfOrigin': country!.code,\n    if (inLists != null) 'onList': inLists,\n    if (isAdult != null) 'isAdult': isAdult,\n    if (isLicensed != null) 'isLicensed': isLicensed,\n  };\n\n  Map<String, dynamic> toPersistenceMap() => {\n    'statuses': statuses.map((e) => e.index).toList(),\n    'animeFormats': animeFormats.map((e) => e.index).toList(),\n    'mangaFormats': mangaFormats.map((e) => e.index).toList(),\n    'genreIn': genreIn,\n    'genreNotIn': genreNotIn,\n    'tagIn': tagIn,\n    'tagNotIn': tagNotIn,\n    'sources': sources.map((e) => e.index).toList(),\n    'sort': sort.index,\n    'season': season?.index,\n    'startYearFrom': startYearFrom,\n    'startYearTo': startYearTo,\n    'country': country?.index,\n    'inLists': inLists,\n    'isAdult': isAdult,\n    'isLicensed': isLicensed,\n  };\n}\n\nclass DiscoverRecommendationsFilter {\n  const DiscoverRecommendationsFilter({this.sort = .recent, this.inLists});\n\n  final RecommendationsSort sort;\n  final bool? inLists;\n\n  DiscoverRecommendationsFilter copyWith({RecommendationsSort? sort, (bool?,)? inLists}) =>\n      DiscoverRecommendationsFilter(\n        sort: sort ?? this.sort,\n        inLists: inLists == null ? this.inLists : inLists.$1,\n      );\n}\n\nenum RecommendationsSort {\n  recent('ID_DESC'),\n  highestRated('RATING_DESC'),\n  lowestRated('RATING');\n\n  const RecommendationsSort(this.value);\n\n  final String value;\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_filter_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/discover/discover_filter_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\n\nfinal discoverFilterProvider = NotifierProvider.autoDispose<DiscoverFilterNotifier, DiscoverFilter>(\n  DiscoverFilterNotifier.new,\n);\n\nclass DiscoverFilterNotifier extends Notifier<DiscoverFilter> {\n  @override\n  DiscoverFilter build() {\n    final mediaFilter = ref.watch(persistenceProvider.select((s) => s.discoverMediaFilter));\n\n    final discoverType = ref.watch(persistenceProvider.select((s) => s.options.discoverType));\n\n    return DiscoverFilter(discoverType, mediaFilter);\n  }\n\n  @override\n  DiscoverFilter get state => super.state;\n\n  @override\n  set state(DiscoverFilter newState) => super.state = newState;\n\n  DiscoverFilter update(DiscoverFilter Function(DiscoverFilter) callback) =>\n      super.state = callback(state);\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_floating_action.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/discover/discover_filter_provider.dart';\nimport 'package:otraku/feature/discover/discover_model.dart';\nimport 'package:otraku/widget/input/pill_selector.dart';\nimport 'package:otraku/widget/swipe_switcher.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass DiscoverFloatingAction extends StatelessWidget {\n  const DiscoverFloatingAction() : super(key: const Key('switchDiscover'));\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, child) {\n        final type = ref.watch(discoverFilterProvider.select((s) => s.type));\n\n        return FloatingActionButton(\n          tooltip: 'Types',\n          onPressed: () {\n            showSheet(\n              context,\n              SimpleSheet(\n                initialHeight: PillSelector.expectedMinHeight(DiscoverType.values.length),\n                builder: (context, scrollCtrl) => PillSelector(\n                  scrollCtrl: scrollCtrl,\n                  selected: type.index,\n                  items: DiscoverType.values.map((v) => Text(v.label)).toList(),\n                  onTap: (i) {\n                    ref\n                        .read(discoverFilterProvider.notifier)\n                        .update((s) => s.copyWith(type: DiscoverType.values[i]));\n                    Navigator.pop(context);\n                  },\n                ),\n              ),\n            );\n          },\n          child: SwipeSwitcher(\n            index: type.index,\n            onChanged: (index) => ref\n                .read(discoverFilterProvider.notifier)\n                .update((s) => s.copyWith(type: DiscoverType.values[index])),\n            children: DiscoverType.values.map((v) => Icon(_typeIcon(v))).toList(),\n          ),\n        );\n      },\n    );\n  }\n\n  static IconData _typeIcon(DiscoverType type) => switch (type) {\n    .anime => Ionicons.film_outline,\n    .manga => Ionicons.book_outline,\n    .character => Ionicons.man_outline,\n    .staff => Ionicons.mic_outline,\n    .studio => Ionicons.business_outline,\n    .user => Ionicons.person_outline,\n    .review => Icons.rate_review_outlined,\n    .recommendation => Icons.thumb_up_outlined,\n  };\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_media_filter_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/discover/discover_filter_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/feature/tag/tag_picker.dart';\nimport 'package:otraku/widget/input/year_range_picker.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/tag/tag_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/navigation_tool.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass DiscoverMediaFilterView extends ConsumerStatefulWidget {\n  const DiscoverMediaFilterView({\n    required this.ofAnime,\n    required this.filter,\n    required this.onChanged,\n  });\n\n  final bool ofAnime;\n  final DiscoverMediaFilter filter;\n  final void Function(DiscoverMediaFilter) onChanged;\n\n  @override\n  ConsumerState<DiscoverMediaFilterView> createState() => _DiscoverFilterViewState();\n}\n\nclass _DiscoverFilterViewState extends ConsumerState<DiscoverMediaFilterView> {\n  late final _filter = widget.filter.copy();\n\n  @override\n  Widget build(BuildContext context) {\n    final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast));\n\n    final applyButton = BottomBarButton(\n      text: 'Apply',\n      icon: Icons.done_rounded,\n      onTap: () {\n        widget.onChanged(_filter);\n        Navigator.pop(context);\n      },\n    );\n\n    final revertToDefaultButton = BottomBarButton(\n      text: 'Reset',\n      icon: Icons.restore_rounded,\n      foregroundColor: ColorScheme.of(context).secondary,\n      onTap: () {\n        widget.onChanged(ref.read(persistenceProvider).discoverMediaFilter);\n        Navigator.pop(context);\n      },\n    );\n\n    final saveButton = BottomBarButton(\n      text: 'Save',\n      icon: Icons.save_outlined,\n      foregroundColor: ColorScheme.of(context).secondary,\n      onTap: () => ConfirmationDialog.show(\n        context,\n        title: 'Make default?',\n        content: 'The current filters and sorting will become the default.',\n        primaryAction: 'Yes',\n        secondaryAction: 'No',\n        onConfirm: () {\n          ref.read(persistenceProvider.notifier).setDiscoverMediaFilter(_filter);\n\n          widget.onChanged(_filter);\n          Navigator.pop(context);\n        },\n      ),\n    );\n\n    return SheetWithButtonRow(\n      buttons: BottomBar(\n        Theming.of(context).rightButtonOrientation\n            ? [saveButton, revertToDefaultButton, applyButton]\n            : [applyButton, revertToDefaultButton, saveButton],\n      ),\n      builder: (context, scrollCtrl) => Padding(\n        padding: const .symmetric(horizontal: Theming.offset),\n        child: ListView(\n          controller: scrollCtrl,\n          padding: const .only(top: 20),\n          children: [\n            ChipSelector.ensureSelected(\n              title: 'Sorting',\n              items: MediaSort.values.map((v) => (v.label, v)).toList(),\n              value: _filter.sort,\n              onChanged: (v) => _filter.sort = v,\n              highContrast: highContrast,\n            ),\n            ChipMultiSelector(\n              title: 'Statuses',\n              items: ReleaseStatus.values.map((v) => (v.label, v)).toList(),\n              values: _filter.statuses,\n              highContrast: highContrast,\n            ),\n            if (widget.ofAnime)\n              ChipMultiSelector(\n                title: 'Formats',\n                items: MediaFormat.animeFormats.map((v) => (v.label, v)).toList(),\n                values: _filter.animeFormats,\n                highContrast: highContrast,\n              )\n            else\n              ChipMultiSelector(\n                title: 'Formats',\n                items: MediaFormat.mangaFormats.map((v) => (v.label, v)).toList(),\n                values: _filter.mangaFormats,\n                highContrast: highContrast,\n              ),\n            if (widget.ofAnime)\n              ChipSelector(\n                title: 'Season',\n                items: MediaSeason.values.map((v) => (v.label, v)).toList(),\n                value: _filter.season,\n                onChanged: (v) => _filter.season = v,\n                highContrast: highContrast,\n              ),\n            const SizedBox(height: 5),\n            const Divider(),\n            Consumer(\n              builder: (context, ref, _) => ref\n                  .watch(tagsProvider)\n                  .when(\n                    loading: () => const Center(child: Loader()),\n                    error: (_, _) => const Center(child: Text('Failed to load tags')),\n                    data: (tags) => TagPicker(\n                      includedGenres: _filter.genreIn,\n                      excludedGenres: _filter.genreNotIn,\n                      includedTags: _filter.tagIn,\n                      excludedTags: _filter.tagNotIn,\n                    ),\n                  ),\n            ),\n            const Divider(),\n            const SizedBox(height: Theming.offset),\n            YearRangePicker(\n              title: 'Release Year Range',\n              from: _filter.startYearFrom,\n              to: _filter.startYearTo,\n              onChanged: (from, to) {\n                _filter.startYearFrom = from;\n                _filter.startYearTo = to;\n              },\n            ),\n            const SizedBox(height: Theming.offset),\n            const Divider(),\n            ChipSelector(\n              title: 'Country',\n              items: OriginCountry.values.map((v) => (v.label, v)).toList(),\n              value: _filter.country,\n              onChanged: (v) => _filter.country = v,\n              highContrast: highContrast,\n            ),\n            ChipMultiSelector(\n              title: 'Sources',\n              items: MediaSource.values.map((v) => (v.label, v)).toList(),\n              values: _filter.sources,\n              highContrast: highContrast,\n            ),\n            ChipSelector(\n              title: 'List Presence',\n              items: const [('In Lists', true), ('Not in Lists', false)],\n              value: _filter.inLists,\n              onChanged: (v) => _filter.inLists = v,\n              highContrast: highContrast,\n            ),\n            ChipSelector(\n              title: 'Age Restriction',\n              items: const [('Adult', true), ('Non-Adult', false)],\n              value: _filter.isAdult,\n              onChanged: (v) => _filter.isAdult = v,\n              highContrast: highContrast,\n            ),\n            ChipSelector(\n              title: 'Licensing',\n              items: const [('Licensed', true), ('Doujin', false)],\n              value: _filter.isLicensed,\n              onChanged: (v) => _filter.isLicensed = v,\n              highContrast: highContrast,\n            ),\n            SizedBox(\n              height: MediaQuery.paddingOf(context).bottom + BottomBar.height + Theming.offset,\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_media_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/discover/discover_model.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/text_rail.dart';\n\nclass DiscoverMediaGrid extends StatelessWidget {\n  const DiscoverMediaGrid(this.items, {required this.highContrast});\n\n  final List<DiscoverMediaItem> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    if (items.isEmpty) {\n      return const SliverFillRemaining(child: Center(child: Text('No Media')));\n    }\n\n    final textTheme = TextTheme.of(context);\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);\n    final labelSmallLineHeight = context.lineHeight(textTheme.labelSmall!);\n    final tileHeight = bodyMediumLineHeight + labelMediumLineHeight * 2 + labelSmallLineHeight + 16;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 290, height: tileHeight),\n      delegate: SliverChildBuilderDelegate(\n        childCount: items.length,\n        (context, index) => _Tile(items[index], highContrast, tileHeight),\n      ),\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile(this.item, this.highContrast, this.tileHeight);\n\n  final DiscoverMediaItem item;\n  final bool highContrast;\n  final double tileHeight;\n\n  @override\n  Widget build(BuildContext context) {\n    final textRailItems = <String, bool>{};\n    if (item.format != null) textRailItems[item.format!] = false;\n    if (item.releaseStatus != null) {\n      textRailItems[item.releaseStatus!.label] = false;\n    }\n    if (item.releaseYear != null) {\n      textRailItems[item.releaseYear!.toString()] = false;\n    }\n\n    if (item.entryStatus != null) {\n      textRailItems[item.entryStatus!.label(item.isAnime)] = true;\n    }\n\n    if (item.isAdult) textRailItems['Adult'] = true;\n\n    final detailTextStyle = TextTheme.of(context).labelSmall;\n\n    return CardExtension.highContrast(highContrast)(\n      child: MediaRouteTile(\n        id: item.id,\n        imageUrl: item.imageUrl,\n        child: Row(\n          children: [\n            Hero(\n              tag: item.id,\n              child: ClipRRect(\n                borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n                child: Container(\n                  width: tileHeight / Theming.coverHtoWRatio,\n                  color: ColorScheme.of(context).surfaceContainerHighest,\n                  child: CachedImage(item.imageUrl),\n                ),\n              ),\n            ),\n            Expanded(\n              child: Padding(\n                padding: .symmetric(horizontal: Theming.offset, vertical: 5),\n                child: Column(\n                  crossAxisAlignment: .start,\n                  mainAxisAlignment: .spaceAround,\n                  spacing: 3,\n                  children: [\n                    Flexible(\n                      child: Column(\n                        mainAxisSize: .min,\n                        crossAxisAlignment: .start,\n                        mainAxisAlignment: .spaceBetween,\n                        spacing: 3,\n                        children: [\n                          Flexible(child: Text(item.name, overflow: .ellipsis, maxLines: 2)),\n                          TextRail(textRailItems, style: TextTheme.of(context).labelMedium),\n                        ],\n                      ),\n                    ),\n                    Row(\n                      spacing: 5,\n                      children: [\n                        Icon(\n                          Icons.percent_rounded,\n                          size: 15,\n                          color: ColorScheme.of(context).onSurfaceVariant,\n                        ),\n                        Text(\n                          item.averageScore.toString(),\n                          style: detailTextStyle,\n                          overflow: .ellipsis,\n                          maxLines: 1,\n                        ),\n                        Icon(\n                          Icons.person_outline_rounded,\n                          size: 15,\n                          color: ColorScheme.of(context).onSurfaceVariant,\n                        ),\n                        Text(item.popularity.toString(), style: detailTextStyle),\n                      ],\n                    ),\n                  ],\n                ),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_media_simple_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/discover/discover_model.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\n\nclass DiscoverMediaSimpleGrid extends StatelessWidget {\n  const DiscoverMediaSimpleGrid(this.items, {required this.highContrast});\n\n  final List<DiscoverMediaItem> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final textHeight = lineHeight * 2 + 10;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight(\n        minWidth: 100,\n        extraHeight: textHeight,\n        rawHWRatio: Theming.coverHtoWRatio,\n      ),\n      delegate: SliverChildBuilderDelegate(\n        (_, i) => _Tile(items[i], highContrast, textHeight),\n        childCount: items.length,\n      ),\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile(this.item, this.highContrast, this.textHeight);\n\n  final DiscoverMediaItem item;\n  final bool highContrast;\n  final double textHeight;\n\n  @override\n  Widget build(BuildContext context) {\n    return MediaRouteTile(\n      id: item.id,\n      imageUrl: item.imageUrl,\n      child: CardExtension.highContrast(highContrast)(\n        child: Column(\n          crossAxisAlignment: .stretch,\n          children: [\n            Expanded(\n              child: Hero(\n                tag: item.id,\n                child: ClipRRect(\n                  borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall),\n                  child: CachedImage(item.imageUrl),\n                ),\n              ),\n            ),\n            SizedBox(\n              height: textHeight,\n              child: Padding(\n                padding: const .all(5),\n                child: Text(item.name, maxLines: 2, overflow: .ellipsis),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_model.dart",
    "content": "import 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/feature/character/character_item_model.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/staff/staff_item_model.dart';\nimport 'package:otraku/feature/studio/studio_item_model.dart';\nimport 'package:otraku/feature/user/user_item_model.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/review/review_models.dart';\n\nenum DiscoverType {\n  anime('Anime'),\n  manga('Manga'),\n  character('Character'),\n  staff('Staff'),\n  studio('Studio'),\n  user('User'),\n  review('Review'),\n  recommendation('Recommendation');\n\n  const DiscoverType(this.label);\n\n  final String label;\n}\n\nenum DiscoverItemView { detailed, simple }\n\nsealed class DiscoverItems {\n  const DiscoverItems();\n}\n\nclass DiscoverAnimeItems extends DiscoverItems {\n  const DiscoverAnimeItems([this.pages = const Paged()]);\n\n  final Paged<DiscoverMediaItem> pages;\n}\n\nclass DiscoverMangaItems extends DiscoverItems {\n  const DiscoverMangaItems([this.pages = const Paged()]);\n\n  final Paged<DiscoverMediaItem> pages;\n}\n\nclass DiscoverCharacterItems extends DiscoverItems {\n  const DiscoverCharacterItems([this.pages = const Paged()]);\n\n  final Paged<CharacterItem> pages;\n}\n\nclass DiscoverStaffItems extends DiscoverItems {\n  const DiscoverStaffItems([this.pages = const Paged()]);\n\n  final Paged<StaffItem> pages;\n}\n\nclass DiscoverStudioItems extends DiscoverItems {\n  const DiscoverStudioItems([this.pages = const Paged()]);\n\n  final Paged<StudioItem> pages;\n}\n\nclass DiscoverUserItems extends DiscoverItems {\n  const DiscoverUserItems([this.pages = const Paged()]);\n\n  final Paged<UserItem> pages;\n}\n\nclass DiscoverReviewItems extends DiscoverItems {\n  const DiscoverReviewItems([this.pages = const Paged()]);\n\n  final Paged<ReviewItem> pages;\n}\n\nclass DiscoverRecommendationItems extends DiscoverItems {\n  const DiscoverRecommendationItems([this.pages = const Paged()]);\n\n  final Paged<DiscoverRecommendationItem> pages;\n}\n\nclass DiscoverMediaItem {\n  DiscoverMediaItem._({\n    required this.id,\n    required this.name,\n    required this.imageUrl,\n    required this.isAnime,\n    required this.format,\n    required this.releaseStatus,\n    required this.entryStatus,\n    required this.releaseYear,\n    required this.averageScore,\n    required this.popularity,\n    required this.isAdult,\n  });\n\n  factory DiscoverMediaItem(Map<String, dynamic> map, ImageQuality imageQuality) =>\n      DiscoverMediaItem._(\n        id: map['id'],\n        name: map['title']['userPreferred'],\n        imageUrl: map['coverImage'][imageQuality.value],\n        isAnime: map['type'] == 'ANIME',\n        format: StringExtension.tryNoScreamingSnakeCase(map['format']),\n        releaseStatus: ReleaseStatus.from(map['status']),\n        entryStatus: ListStatus.from(map['mediaListEntry']?['status']),\n        releaseYear: map['startDate']?['year'],\n        averageScore: map['averageScore'] ?? 0,\n        popularity: map['popularity'] ?? 0,\n        isAdult: map['isAdult'] ?? false,\n      );\n\n  final int id;\n  final String name;\n  final String imageUrl;\n  final bool isAnime;\n  final String? format;\n  final ReleaseStatus? releaseStatus;\n  final ListStatus? entryStatus;\n  final int? releaseYear;\n  final int averageScore;\n  final int popularity;\n  final bool isAdult;\n}\n\nclass DiscoverRecommendationItem {\n  DiscoverRecommendationItem._({\n    required this.rating,\n    required this.userRating,\n    required this.mediaId,\n    required this.mediaTitle,\n    required this.mediaCover,\n    required this.mediaListStatus,\n    required this.isMediaAdult,\n    required this.recommendedMediaId,\n    required this.recommendedMediaTitle,\n    required this.recommendedMediaCover,\n    required this.recommendedMediaListStatus,\n    required this.isRecommendedMediaAdult,\n  });\n\n  factory DiscoverRecommendationItem(Map<String, dynamic> map, ImageQuality imageQuality) {\n    final userRating = map['userRating'] == 'RATE_UP'\n        ? true\n        : map['userRating'] == 'RATE_DOWN'\n        ? false\n        : null;\n\n    final media = map['media'];\n    final recommendedMedia = map['mediaRecommendation'];\n\n    final isMediaAnime = switch (media['type']) {\n      'ANIME' => true,\n      'MANGA' => false,\n      _ => null,\n    };\n    final isRecommendedMediaAnime = switch (media['type']) {\n      'ANIME' => true,\n      'MANGA' => false,\n      _ => null,\n    };\n\n    return DiscoverRecommendationItem._(\n      userRating: userRating,\n      rating: map['rating'] ?? 0,\n      mediaId: media['id'] ?? 0,\n      mediaTitle: media['title']['userPreferred'] ?? '?',\n      mediaCover: media['coverImage'][imageQuality.value] ?? '',\n      mediaListStatus: ListStatus.from(media['mediaListEntry']?['status'])?.label(isMediaAnime),\n      isMediaAdult: media['isAdult'] ?? false,\n      recommendedMediaId: recommendedMedia['id'] ?? 0,\n      recommendedMediaTitle: recommendedMedia['title']['userPreferred'] ?? '?',\n      recommendedMediaCover: recommendedMedia['coverImage'][imageQuality.value] ?? '',\n      recommendedMediaListStatus: ListStatus.from(\n        recommendedMedia['mediaListEntry']?['status'],\n      )?.label(isRecommendedMediaAnime),\n      isRecommendedMediaAdult: recommendedMedia['isAdult'] ?? false,\n    );\n  }\n\n  int rating;\n  bool? userRating;\n  final int mediaId;\n  final String mediaTitle;\n  final String mediaCover;\n  final String? mediaListStatus;\n  final bool isMediaAdult;\n  final int recommendedMediaId;\n  final String recommendedMediaTitle;\n  final String recommendedMediaCover;\n  final String? recommendedMediaListStatus;\n  final bool isRecommendedMediaAdult;\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/feature/character/character_item_model.dart';\nimport 'package:otraku/feature/discover/discover_filter_model.dart';\nimport 'package:otraku/feature/staff/staff_item_model.dart';\nimport 'package:otraku/feature/studio/studio_item_model.dart';\nimport 'package:otraku/feature/user/user_item_model.dart';\nimport 'package:otraku/feature/discover/discover_filter_provider.dart';\nimport 'package:otraku/feature/discover/discover_model.dart';\nimport 'package:otraku/feature/review/review_models.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal discoverProvider = AsyncNotifierProvider<DiscoverNotifier, DiscoverItems>(\n  DiscoverNotifier.new,\n);\n\nclass DiscoverNotifier extends AsyncNotifier<DiscoverItems> {\n  late DiscoverFilter filter;\n\n  @override\n  FutureOr<DiscoverItems> build() {\n    filter = ref.watch(discoverFilterProvider);\n    return switch (filter.type) {\n      .anime => _fetchAnime(const DiscoverAnimeItems()),\n      .manga => _fetchManga(const DiscoverMangaItems()),\n      .character => _fetchCharacters(const DiscoverCharacterItems()),\n      .staff => _fetchStaff(const DiscoverStaffItems()),\n      .studio => _fetchStudios(const DiscoverStudioItems()),\n      .user => _fetchUsers(const DiscoverUserItems()),\n      .review => _fetchReviews(const DiscoverReviewItems()),\n      .recommendation => _fetchRecommendations(const DiscoverRecommendationItems()),\n    };\n  }\n\n  Future<void> fetch() async {\n    final oldValue = state.value;\n    state = await AsyncValue.guard(\n      () => switch (filter.type) {\n        .anime => _fetchAnime(\n          (oldValue is DiscoverAnimeItems) ? oldValue : const DiscoverAnimeItems(),\n        ),\n        .manga => _fetchManga(\n          (oldValue is DiscoverMangaItems) ? oldValue : const DiscoverMangaItems(),\n        ),\n        .character => _fetchCharacters(\n          (oldValue is DiscoverCharacterItems) ? oldValue : const DiscoverCharacterItems(),\n        ),\n        .staff => _fetchStaff(\n          (oldValue is DiscoverStaffItems) ? oldValue : const DiscoverStaffItems(),\n        ),\n        .studio => _fetchStudios(\n          (oldValue is DiscoverStudioItems) ? oldValue : const DiscoverStudioItems(),\n        ),\n        .user => _fetchUsers(\n          (oldValue is DiscoverUserItems) ? oldValue : const DiscoverUserItems(),\n        ),\n        .review => _fetchReviews(\n          (oldValue is DiscoverReviewItems) ? oldValue : const DiscoverReviewItems(),\n        ),\n        .recommendation => _fetchRecommendations(\n          (oldValue is DiscoverRecommendationItems)\n              ? oldValue\n              : const DiscoverRecommendationItems(),\n        ),\n      },\n    );\n  }\n\n  Future<DiscoverItems> _fetchAnime(DiscoverAnimeItems oldValue) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.mediaPage, {\n      'page': oldValue.pages.next,\n      'type': 'ANIME',\n      if (filter.search.isNotEmpty) ...{\n        'search': filter.search,\n        ...filter.mediaFilter.toGraphQlVariables(ofAnime: true)..['sort'] = 'SEARCH_MATCH',\n      } else\n        ...filter.mediaFilter.toGraphQlVariables(ofAnime: true),\n    });\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    final items = <DiscoverMediaItem>[];\n    for (final m in data['Page']['media']) {\n      items.add(DiscoverMediaItem(m, imageQuality));\n    }\n\n    return DiscoverAnimeItems(\n      oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false),\n    );\n  }\n\n  Future<DiscoverItems> _fetchManga(DiscoverMangaItems oldValue) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.mediaPage, {\n      'page': oldValue.pages.next,\n      'type': 'MANGA',\n      if (filter.search.isNotEmpty) ...{\n        'search': filter.search,\n        ...filter.mediaFilter.toGraphQlVariables(ofAnime: false)..['sort'] = 'SEARCH_MATCH',\n      } else\n        ...filter.mediaFilter.toGraphQlVariables(ofAnime: false),\n    });\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    final items = <DiscoverMediaItem>[];\n    for (final m in data['Page']['media']) {\n      items.add(DiscoverMediaItem(m, imageQuality));\n    }\n\n    return DiscoverMangaItems(\n      oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false),\n    );\n  }\n\n  Future<DiscoverItems> _fetchCharacters(DiscoverCharacterItems oldValue) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.characterPage, {\n      'page': oldValue.pages.next,\n      if (filter.search.isNotEmpty) 'search': filter.search,\n      if (filter.hasBirthday) 'isBirthday': true,\n    });\n\n    final items = <CharacterItem>[];\n    for (final c in data['Page']['characters']) {\n      items.add(CharacterItem(c));\n    }\n\n    return DiscoverCharacterItems(\n      oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false),\n    );\n  }\n\n  Future<DiscoverItems> _fetchStaff(DiscoverStaffItems oldValue) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.staffPage, {\n      'page': oldValue.pages.next,\n      if (filter.search.isNotEmpty) 'search': filter.search,\n      if (filter.hasBirthday) 'isBirthday': true,\n    });\n\n    final items = <StaffItem>[];\n    for (final s in data['Page']['staff']) {\n      items.add(StaffItem(s));\n    }\n\n    return DiscoverStaffItems(\n      oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false),\n    );\n  }\n\n  Future<DiscoverItems> _fetchStudios(DiscoverStudioItems oldValue) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.studioPage, {\n      'page': oldValue.pages.next,\n      if (filter.search.isNotEmpty) 'search': filter.search,\n    });\n\n    final items = <StudioItem>[];\n    for (final s in data['Page']['studios']) {\n      items.add(StudioItem(s));\n    }\n\n    return DiscoverStudioItems(\n      oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false),\n    );\n  }\n\n  Future<DiscoverItems> _fetchUsers(DiscoverUserItems oldValue) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.userPage, {\n      'page': oldValue.pages.next,\n      if (filter.search.isNotEmpty) 'search': filter.search,\n    });\n\n    final items = <UserItem>[];\n    for (final u in data['Page']['users']) {\n      items.add(UserItem(u));\n    }\n\n    return DiscoverUserItems(\n      oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false),\n    );\n  }\n\n  Future<DiscoverItems> _fetchReviews(DiscoverReviewItems oldValue) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.reviewPage, {\n      'page': oldValue.pages.next,\n      'sort': filter.reviewsFilter.sort.value,\n      if (filter.reviewsFilter.mediaType != null)\n        'mediaType': filter.reviewsFilter.mediaType!.value,\n    });\n\n    final items = <ReviewItem>[];\n    for (final r in data['Page']['reviews']) {\n      items.add(ReviewItem(r));\n    }\n\n    return DiscoverReviewItems(\n      oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false),\n    );\n  }\n\n  Future<DiscoverItems> _fetchRecommendations(DiscoverRecommendationItems oldValue) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.recommendationsPage, {\n      'page': oldValue.pages.next,\n      'sort': filter.recommendationsFilter.sort.value,\n      if (filter.recommendationsFilter.inLists != null)\n        'onList': filter.recommendationsFilter.inLists,\n    });\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    final items = <DiscoverRecommendationItem>[];\n    for (final r in data['Page']['recommendations']) {\n      items.add(DiscoverRecommendationItem(r, imageQuality));\n    }\n\n    return DiscoverRecommendationItems(\n      oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false),\n    );\n  }\n\n  Future<Object?> rateRecommendation(int mediaId, int recommendedMediaId, bool? rating) {\n    return ref.read(repositoryProvider).request(GqlMutation.rateRecommendation, {\n      'id': mediaId,\n      'recommendedId': recommendedMediaId,\n      'rating': rating == null\n          ? 'NO_RATING'\n          : rating\n          ? 'RATE_UP'\n          : 'RATE_DOWN',\n    }).getErrorOrNull();\n  }\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_recommendations_filter_sheet.dart",
    "content": "import 'package:flutter/widgets.dart';\nimport 'package:otraku/feature/discover/discover_filter_model.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nFuture<void> showRecommendationsFilterSheet({\n  required BuildContext context,\n  required DiscoverRecommendationsFilter filter,\n  required void Function(DiscoverRecommendationsFilter) onDone,\n  required bool highContrast,\n}) {\n  return showSheet(\n    context,\n    SimpleSheet(\n      initialHeight: Theming.normalTapTarget * 2.5 + MediaQuery.paddingOf(context).bottom + 40,\n      builder: (context, scrollCtrl) => ListView(\n        controller: scrollCtrl,\n        physics: Theming.bouncyPhysics,\n        padding: const .symmetric(horizontal: Theming.offset, vertical: 20),\n        children: [\n          ChipSelector.ensureSelected(\n            title: 'Sort',\n            items: const [\n              ('Recent', RecommendationsSort.recent),\n              ('Highest Rated', RecommendationsSort.highestRated),\n              ('Lowest Rated', RecommendationsSort.lowestRated),\n            ],\n            value: filter.sort,\n            onChanged: (v) => filter = filter.copyWith(sort: v),\n            highContrast: highContrast,\n          ),\n          ChipSelector(\n            title: 'List Presence',\n            items: const [('In Lists', true), ('Not in Lists', false)],\n            value: filter.inLists,\n            onChanged: (v) => filter = filter.copyWith(inLists: (v,)),\n            highContrast: highContrast,\n          ),\n        ],\n      ),\n    ),\n  ).then((_) => onDone(filter));\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_recommendations_grid.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/discover/discover_model.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\n\ntypedef OnRateRecommendation =\n    Future<Object?> Function(int mediaId, int recommendedMediaId, bool? rating);\n\nclass DiscoverRecommendationsGrid extends StatelessWidget {\n  const DiscoverRecommendationsGrid(this.items, {required this.onRate, required this.highContrast});\n\n  final List<DiscoverRecommendationItem> items;\n  final OnRateRecommendation onRate;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    if (items.isEmpty) {\n      return const SliverFillRemaining(child: Center(child: Text('No items')));\n    }\n\n    final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final presentationHeight = bodyMediumLineHeight * 4 + 13;\n    final ratingHeight = max(bodyMediumLineHeight, Theming.minTapTarget);\n    final tileHeight = presentationHeight + ratingHeight;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 300, height: tileHeight),\n      delegate: SliverChildBuilderDelegate(\n        childCount: items.length,\n        (context, i) => _Tile(items[i], onRate, highContrast, presentationHeight),\n      ),\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile(this.item, this.onRate, this.highContrast, this.presentationHeight);\n\n  final DiscoverRecommendationItem item;\n  final OnRateRecommendation onRate;\n  final bool highContrast;\n  final double presentationHeight;\n\n  @override\n  Widget build(BuildContext context) {\n    final coverWidth = presentationHeight / Theming.coverHtoWRatio;\n\n    return CardExtension.highContrast(highContrast)(\n      child: Column(\n        children: [\n          SizedBox(\n            height: presentationHeight,\n            child: Row(\n              children: [\n                MediaRouteTile(\n                  id: item.mediaId,\n                  imageUrl: item.mediaCover,\n                  child: ClipRRect(\n                    borderRadius: Theming.borderRadiusSmall,\n                    child: CachedImage(item.mediaCover, width: coverWidth),\n                  ),\n                ),\n                Expanded(\n                  child: Padding(\n                    padding: const .symmetric(horizontal: Theming.offset, vertical: 5),\n                    child: Column(\n                      crossAxisAlignment: .stretch,\n                      mainAxisAlignment: .spaceAround,\n                      children: [\n                        GestureDetector(\n                          behavior: .opaque,\n                          onTap: () => context.push(Routes.media(item.mediaId, item.mediaCover)),\n                          child: Text(item.mediaTitle, overflow: .ellipsis, maxLines: 2),\n                        ),\n                        const Divider(height: 3),\n                        GestureDetector(\n                          behavior: .opaque,\n                          onTap: () => context.push(\n                            Routes.media(item.recommendedMediaId, item.recommendedMediaCover),\n                          ),\n                          child: Text(\n                            item.recommendedMediaTitle,\n                            overflow: .ellipsis,\n                            textAlign: .end,\n                            maxLines: 2,\n                          ),\n                        ),\n                      ],\n                    ),\n                  ),\n                ),\n                MediaRouteTile(\n                  id: item.recommendedMediaId,\n                  imageUrl: item.recommendedMediaCover,\n                  child: ClipRRect(\n                    borderRadius: Theming.borderRadiusSmall,\n                    child: CachedImage(item.recommendedMediaCover, width: coverWidth),\n                  ),\n                ),\n              ],\n            ),\n          ),\n          Padding(\n            padding: const .symmetric(horizontal: Theming.offset),\n            child: Row(\n              spacing: 5,\n              children: [\n                Expanded(\n                  child: item.mediaListStatus == null\n                      ? const SizedBox()\n                      : Text(\n                          item.mediaListStatus!,\n                          textAlign: .left,\n                          overflow: .ellipsis,\n                          maxLines: 1,\n                        ),\n                ),\n                _RecommendationButtons(item, onRate),\n                Expanded(\n                  child: item.recommendedMediaListStatus == null\n                      ? const SizedBox()\n                      : Text(\n                          item.recommendedMediaListStatus!,\n                          textAlign: .right,\n                          overflow: .ellipsis,\n                          maxLines: 1,\n                        ),\n                ),\n              ],\n            ),\n          ),\n        ],\n      ),\n    );\n  }\n}\n\nclass _RecommendationButtons extends StatefulWidget {\n  const _RecommendationButtons(this.item, this.onRate);\n\n  final DiscoverRecommendationItem item;\n  final OnRateRecommendation onRate;\n\n  @override\n  State<_RecommendationButtons> createState() => __RecommendationButtonsState();\n}\n\nclass __RecommendationButtonsState extends State<_RecommendationButtons> {\n  @override\n  Widget build(BuildContext context) {\n    final item = widget.item;\n\n    return Row(\n      spacing: 5,\n      mainAxisAlignment: .center,\n      children: [\n        IconButton(\n          tooltip: 'Agree',\n          icon: item.userRating == true\n              ? Icon(\n                  Icons.thumb_up,\n                  size: Theming.iconSmall,\n                  color: ColorScheme.of(context).primary,\n                )\n              : Icon(\n                  Icons.thumb_up_outlined,\n                  size: Theming.iconSmall,\n                  color: ColorScheme.of(context).onSurface,\n                ),\n          onPressed: () async {\n            final oldRating = item.rating;\n            final oldUserRating = item.userRating;\n\n            setState(() {\n              switch (item.userRating) {\n                case true:\n                  item.rating--;\n                  item.userRating = null;\n                  break;\n                case false:\n                  item.rating += 2;\n                  item.userRating = true;\n                  break;\n                case null:\n                  item.rating++;\n                  item.userRating = true;\n                  break;\n              }\n            });\n\n            final err = await widget.onRate(item.mediaId, item.recommendedMediaId, item.userRating);\n            if (err == null) return;\n\n            setState(() {\n              item.rating = oldRating;\n              item.userRating = oldUserRating;\n            });\n\n            if (context.mounted) {\n              SnackBarExtension.show(context, err.toString());\n            }\n          },\n        ),\n        Text(item.rating.toString()),\n        IconButton(\n          tooltip: 'Disagree',\n          icon: item.userRating == false\n              ? Icon(\n                  Icons.thumb_down,\n                  size: Theming.iconSmall,\n                  color: ColorScheme.of(context).error,\n                )\n              : Icon(\n                  Icons.thumb_down_outlined,\n                  size: Theming.iconSmall,\n                  color: ColorScheme.of(context).onSurface,\n                ),\n          onPressed: () async {\n            final oldRating = item.rating;\n            final oldUserRating = item.userRating;\n\n            setState(() {\n              switch (item.userRating) {\n                case true:\n                  item.rating -= 2;\n                  item.userRating = false;\n                  break;\n                case false:\n                  item.rating++;\n                  item.userRating = null;\n                  break;\n                case null:\n                  item.rating--;\n                  item.userRating = false;\n                  break;\n              }\n            });\n\n            final err = await widget.onRate(item.mediaId, item.recommendedMediaId, item.userRating);\n            if (err == null) return;\n\n            setState(() {\n              item.rating = oldRating;\n              item.userRating = oldUserRating;\n            });\n\n            if (context.mounted) {\n              SnackBarExtension.show(context, err.toString());\n            }\n          },\n        ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_top_bar.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/discover/discover_filter_model.dart';\nimport 'package:otraku/feature/discover/discover_filter_provider.dart';\nimport 'package:otraku/feature/discover/discover_media_filter_view.dart';\nimport 'package:otraku/feature/discover/discover_recommendations_filter_sheet.dart';\nimport 'package:otraku/feature/review/reviews_filter_sheet.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/util/debounce.dart';\nimport 'package:otraku/widget/input/search_field.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass DiscoverTopBarTrailingContent extends StatelessWidget {\n  const DiscoverTopBarTrailingContent(this.focusNode);\n\n  final FocusNode focusNode;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        final filter = ref.watch(discoverFilterProvider);\n        final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast));\n\n        return Expanded(\n          child: Row(\n            children: [\n              Expanded(\n                child: switch (filter.type) {\n                  .review => Text(\n                    'Reviews',\n                    maxLines: 1,\n                    overflow: .ellipsis,\n                    style: TextTheme.of(context).bodyMedium,\n                  ),\n                  .recommendation => Text(\n                    'Recommendations',\n                    maxLines: 1,\n                    overflow: .ellipsis,\n                    style: TextTheme.of(context).bodyMedium,\n                  ),\n                  _ => SearchField(\n                    debounce: Debounce(),\n                    focusNode: focusNode,\n                    hint: filter.type.label,\n                    value: filter.search,\n                    onChanged: (search) => ref\n                        .read(discoverFilterProvider.notifier)\n                        .update((s) => s.copyWith(search: search)),\n                  ),\n                },\n              ),\n              if (filter.type == .anime)\n                IconButton(\n                  tooltip: 'Calendar',\n                  icon: const Icon(Ionicons.calendar_outline),\n                  onPressed: () => context.push(Routes.calendar),\n                ),\n              switch (filter.type) {\n                .anime || .manga =>\n                  filter.mediaFilter.isActive\n                      ? Badge(\n                          smallSize: 10,\n                          alignment: Alignment.topLeft,\n                          backgroundColor: ColorScheme.of(context).primary,\n                          child: _filterIcon(context, ref, filter),\n                        )\n                      : _filterIcon(context, ref, filter),\n                .character || .staff => _BirthdayFilter(ref),\n                .review => IconButton(\n                  tooltip: 'Filter',\n                  icon: const Icon(Ionicons.funnel_outline),\n                  onPressed: () => showReviewsFilterSheet(\n                    context: context,\n                    filter: filter.reviewsFilter,\n                    highContrast: highContrast,\n                    onDone: (filter) {\n                      final discoverFilter = ref.read(discoverFilterProvider);\n                      if (filter != discoverFilter.reviewsFilter) {\n                        ref\n                            .read(discoverFilterProvider.notifier)\n                            .update((s) => s.copyWith(reviewsFilter: filter));\n                      }\n                    },\n                  ),\n                ),\n                .recommendation => IconButton(\n                  tooltip: 'Filter',\n                  icon: const Icon(Ionicons.funnel_outline),\n                  onPressed: () => showRecommendationsFilterSheet(\n                    context: context,\n                    filter: filter.recommendationsFilter,\n                    highContrast: highContrast,\n                    onDone: (filter) {\n                      final discoverFilter = ref.read(discoverFilterProvider);\n                      if (filter != discoverFilter.recommendationsFilter) {\n                        ref\n                            .read(discoverFilterProvider.notifier)\n                            .update((s) => s.copyWith(recommendationsFilter: filter));\n                      }\n                    },\n                  ),\n                ),\n                _ => const SizedBox(width: Theming.offset),\n              },\n            ],\n          ),\n        );\n      },\n    );\n  }\n\n  Widget _filterIcon(BuildContext context, WidgetRef ref, DiscoverFilter filter) {\n    return IconButton(\n      tooltip: 'Filter',\n      icon: const Icon(Ionicons.funnel_outline),\n      onPressed: () => showSheet(\n        context,\n        DiscoverMediaFilterView(\n          ofAnime: filter.type == .anime,\n          filter: filter.mediaFilter,\n          onChanged: (mediaFilter) => ref\n              .read(discoverFilterProvider.notifier)\n              .update((s) => s.copyWith(mediaFilter: mediaFilter)),\n        ),\n      ),\n    );\n  }\n}\n\nclass _BirthdayFilter extends StatelessWidget {\n  const _BirthdayFilter(this.ref);\n\n  final WidgetRef ref;\n\n  @override\n  Widget build(BuildContext context) {\n    final hasBirthday = ref.watch(discoverFilterProvider.select((s) => s.hasBirthday));\n\n    final icon = IconButton(\n      tooltip: 'Birthday Filter',\n      icon: const Icon(Icons.cake_outlined),\n      onPressed: () => ref\n          .read(discoverFilterProvider.notifier)\n          .update((s) => s.copyWith(hasBirthday: !hasBirthday)),\n    );\n\n    return hasBirthday\n        ? Badge(\n            smallSize: 10,\n            alignment: Alignment.topLeft,\n            backgroundColor: ColorScheme.of(context).primary,\n            child: icon,\n          )\n        : icon;\n  }\n}\n"
  },
  {
    "path": "lib/feature/discover/discover_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/character/character_item_grid.dart';\nimport 'package:otraku/feature/discover/discover_filter_provider.dart';\nimport 'package:otraku/feature/discover/discover_media_grid.dart';\nimport 'package:otraku/feature/discover/discover_media_simple_grid.dart';\nimport 'package:otraku/feature/discover/discover_model.dart';\nimport 'package:otraku/feature/discover/discover_provider.dart';\nimport 'package:otraku/feature/discover/discover_recommendations_grid.dart';\nimport 'package:otraku/feature/staff/staff_item_grid.dart';\nimport 'package:otraku/feature/studio/studio_item_grid.dart';\nimport 'package:otraku/feature/user/user_item_grid.dart';\nimport 'package:otraku/feature/review/review_grid.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/input/pill_selector.dart';\nimport 'package:otraku/widget/paged_view.dart';\n\nclass DiscoverSubview extends StatelessWidget {\n  const DiscoverSubview(this.scrollCtrl, this.formFactor);\n\n  final ScrollController scrollCtrl;\n  final FormFactor formFactor;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        final options = ref.watch(persistenceProvider.select((s) => s.options));\n        final type = ref.watch(discoverFilterProvider.select((s) => s.type));\n        final onRefresh = (invalidate) => invalidate(discoverProvider);\n\n        final content = switch (type) {\n          .anime => PagedView(\n            scrollCtrl: scrollCtrl,\n            onRefresh: onRefresh,\n            provider: discoverProvider.select(\n              (s) => s.whenData((data) => (data as DiscoverAnimeItems).pages),\n            ),\n            onData: (data) => options.discoverItemView == .simple\n                ? DiscoverMediaSimpleGrid(data.items, highContrast: options.highContrast)\n                : DiscoverMediaGrid(data.items, highContrast: options.highContrast),\n          ),\n          .manga => PagedView(\n            scrollCtrl: scrollCtrl,\n            onRefresh: onRefresh,\n            provider: discoverProvider.select(\n              (s) => s.whenData((data) => (data as DiscoverMangaItems).pages),\n            ),\n            onData: (data) => options.discoverItemView == .simple\n                ? DiscoverMediaSimpleGrid(data.items, highContrast: options.highContrast)\n                : DiscoverMediaGrid(data.items, highContrast: options.highContrast),\n          ),\n          .character => PagedView(\n            scrollCtrl: scrollCtrl,\n            onRefresh: onRefresh,\n            provider: discoverProvider.select(\n              (s) => s.whenData((data) => (data as DiscoverCharacterItems).pages),\n            ),\n            onData: (data) => CharacterItemGrid(data.items, highContrast: options.highContrast),\n          ),\n          .staff => PagedView(\n            scrollCtrl: scrollCtrl,\n            onRefresh: onRefresh,\n            provider: discoverProvider.select(\n              (s) => s.whenData((data) => (data as DiscoverStaffItems).pages),\n            ),\n            onData: (data) => StaffItemGrid(data.items, highContrast: options.highContrast),\n          ),\n          .studio => PagedView(\n            scrollCtrl: scrollCtrl,\n            onRefresh: onRefresh,\n            provider: discoverProvider.select(\n              (s) => s.whenData((data) => (data as DiscoverStudioItems).pages),\n            ),\n            onData: (data) => StudioItemGrid(data.items, highContrast: options.highContrast),\n          ),\n          .user => PagedView(\n            scrollCtrl: scrollCtrl,\n            onRefresh: onRefresh,\n            provider: discoverProvider.select(\n              (s) => s.whenData((data) => (data as DiscoverUserItems).pages),\n            ),\n            onData: (data) => UserItemGrid(data.items, highContrast: options.highContrast),\n          ),\n          .review => PagedView(\n            scrollCtrl: scrollCtrl,\n            onRefresh: onRefresh,\n            provider: discoverProvider.select(\n              (s) => s.whenData((data) => (data as DiscoverReviewItems).pages),\n            ),\n            onData: (data) => ReviewGrid(data.items, options.highContrast),\n          ),\n          .recommendation => PagedView(\n            scrollCtrl: scrollCtrl,\n            onRefresh: onRefresh,\n            provider: discoverProvider.select(\n              (s) => s.whenData((data) => (data as DiscoverRecommendationItems).pages),\n            ),\n            onData: (data) => DiscoverRecommendationsGrid(\n              data.items,\n              onRate: (mediaId, recommendedMediaId, rating) => ref\n                  .read(discoverProvider.notifier)\n                  .rateRecommendation(mediaId, recommendedMediaId, rating),\n              highContrast: options.highContrast,\n            ),\n          ),\n        };\n\n        if (formFactor == .phone) return content;\n\n        return Row(\n          children: [\n            PillSelector(\n              selected: type.index,\n              maxWidth: 180,\n              items: DiscoverType.values.map((v) => Text(v.label)).toList(),\n              onTap: (i) => ref\n                  .read(discoverFilterProvider.notifier)\n                  .update((s) => s.copyWith(type: DiscoverType.values[i])),\n            ),\n            Expanded(child: content),\n          ],\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/edit/edit_buttons.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/edit/edit_model.dart';\nimport 'package:otraku/feature/edit/edit_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/navigation_tool.dart';\nimport 'package:otraku/widget/dialogs.dart';\n\nclass EditButtons extends StatelessWidget {\n  const EditButtons(this.ref, this.tag, this.entryEdit, this.callback);\n\n  final WidgetRef ref;\n  final EditTag tag;\n  final EntryEdit? entryEdit;\n  final void Function(EntryEdit)? callback;\n\n  @override\n  Widget build(BuildContext context) {\n    final entryEdit = this.entryEdit;\n    if (entryEdit == null) return const SizedBox();\n\n    final saveButton = BottomBarButton(\n      text: 'Save',\n      icon: Ionicons.save_outline,\n      onTap: () async {\n        final err = await ref.read(entryEditProvider(tag).notifier).save();\n\n        if (err == null) {\n          callback?.call(entryEdit);\n          if (context.mounted) Navigator.pop(context);\n          return;\n        }\n\n        if (context.mounted) {\n          SnackBarExtension.show(context, 'Could not update entry');\n          Navigator.pop(context);\n        }\n      },\n    );\n\n    final removeButton = entryEdit.baseEntry.entryId == null\n        ? const Spacer()\n        : BottomBarButton(\n            text: 'Remove',\n            icon: Ionicons.trash_bin_outline,\n            foregroundColor: ColorScheme.of(context).error,\n            onTap: () => ConfirmationDialog.show(\n              context,\n              title: 'Remove entry?',\n              primaryAction: 'Yes',\n              secondaryAction: 'No',\n              onConfirm: () async {\n                final err = await ref.read(entryEditProvider(tag).notifier).remove();\n\n                if (err == null) {\n                  callback?.call(entryEdit);\n                  if (context.mounted) Navigator.pop(context);\n                  return;\n                }\n\n                if (context.mounted) {\n                  SnackBarExtension.show(context, 'Could not remove entry');\n                  Navigator.pop(context);\n                }\n              },\n            ),\n          );\n\n    return BottomBar(\n      Theming.of(context).rightButtonOrientation\n          ? [removeButton, saveButton]\n          : [saveButton, removeButton],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/edit/edit_model.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/settings/settings_model.dart';\n\ntypedef EditTag = ({int id, bool setComplete});\n\nclass EntryEdit {\n  EntryEdit._({\n    required this.baseEntry,\n    required this.listStatus,\n    required this.progress,\n    required this.progressVolumes,\n    required this.score,\n    required this.repeat,\n    required this.startedAt,\n    required this.completedAt,\n    required this.private,\n    required this.hiddenFromStatusLists,\n    required this.advancedScores,\n    required this.customLists,\n    required this.notes,\n  });\n\n  factory EntryEdit(Map<String, dynamic> map, Settings settings, bool setComplete) {\n    final baseEntry = BaseEntry(map);\n\n    final customLists = <String, bool>{};\n    if (map['mediaListEntry']?['customLists'] != null) {\n      for (final e in map['mediaListEntry']['customLists'].entries) {\n        customLists[e.key] = e.value;\n      }\n    } else {\n      if (map['type'] == 'ANIME') {\n        for (final listName in settings.animeCustomLists) {\n          customLists[listName] = false;\n        }\n      } else {\n        for (final listName in settings.mangaCustomLists) {\n          customLists[listName] = false;\n        }\n      }\n    }\n\n    final advancedScores = <String, double>{};\n    if (map['mediaListEntry']?['advancedScores'] != null) {\n      for (final e in map['mediaListEntry']['advancedScores'].entries) {\n        advancedScores[e.key] = e.value.toDouble();\n      }\n    } else if (settings.advancedScoringEnabled) {\n      for (final scoreCategory in settings.advancedScoreSections) {\n        advancedScores[scoreCategory] = 0;\n      }\n    }\n\n    var listStatus = baseEntry.listStatus;\n    var completedAt = baseEntry.completedAt;\n    var progress = baseEntry.progress;\n    if (setComplete) {\n      listStatus = .completed;\n      completedAt ??= DateTime.now();\n\n      if (baseEntry.progressMax != null) progress = baseEntry.progressMax!;\n    }\n\n    return EntryEdit._(\n      baseEntry: baseEntry,\n      listStatus: listStatus,\n      progress: progress,\n      progressVolumes: baseEntry.progressVolumes,\n      score: (map['mediaListEntry']?['score'] ?? 0).toDouble(),\n      repeat: map['mediaListEntry']?['repeat'] ?? 0,\n      notes: map['mediaListEntry']?['notes'] ?? '',\n      startedAt: baseEntry.startedAt,\n      completedAt: completedAt,\n      private: map['mediaListEntry']?['private'] ?? false,\n      hiddenFromStatusLists: map['mediaListEntry']?['hiddenFromStatusLists'] ?? false,\n      advancedScores: advancedScores,\n      customLists: customLists,\n    );\n  }\n\n  final BaseEntry baseEntry;\n  final ListStatus? listStatus;\n  final int progress;\n  final DateTime? startedAt;\n  final DateTime? completedAt;\n  final Map<String, double> advancedScores;\n  final Map<String, bool> customLists;\n  int progressVolumes;\n  double score;\n  int repeat;\n  String notes;\n  bool private;\n  bool hiddenFromStatusLists;\n\n  EntryEdit copyWith({\n    ListStatus? listStatus,\n    int? progress,\n    int? progressVolumes,\n    double? score,\n    int? repeat,\n    String? notes,\n    (DateTime?,)? startedAt,\n    (DateTime?,)? completedAt,\n    bool? private,\n    bool? hiddenFromStatusLists,\n    Map<String, double>? advancedScores,\n    Map<String, bool>? customLists,\n  }) => EntryEdit._(\n    baseEntry: baseEntry,\n    listStatus: listStatus ?? this.listStatus,\n    progress: progress ?? this.progress,\n    progressVolumes: progressVolumes ?? this.progressVolumes,\n    score: score ?? this.score,\n    repeat: repeat ?? this.repeat,\n    notes: notes ?? this.notes,\n    startedAt: startedAt == null ? this.startedAt : startedAt.$1,\n    completedAt: completedAt == null ? this.completedAt : completedAt.$1,\n    private: private ?? this.private,\n    hiddenFromStatusLists: hiddenFromStatusLists ?? this.hiddenFromStatusLists,\n    advancedScores: advancedScores ?? this.advancedScores,\n    customLists: customLists ?? this.customLists,\n  );\n\n  Map<String, dynamic> toGraphQlVariables() => {\n    'mediaId': baseEntry.mediaId,\n    'status': (listStatus ?? ListStatus.current).value,\n    'progress': progress,\n    'progressVolumes': progressVolumes,\n    'score': score,\n    'repeat': repeat,\n    'notes': notes,\n    'startedAt': startedAt?.fuzzyDate,\n    'completedAt': completedAt?.fuzzyDate,\n    'private': private,\n    'hiddenFromStatusLists': hiddenFromStatusLists,\n    'advancedScores': advancedScores.entries.map((e) => e.value).toList(),\n    'customLists': customLists.entries.where((e) => e.value).map((e) => e.key).toList(),\n  };\n}\n\nclass BaseEntry {\n  const BaseEntry._({\n    required this.mediaId,\n    required this.entryId,\n    required this.isAnime,\n    required this.listStatus,\n    required this.progress,\n    required this.progressMax,\n    required this.progressVolumes,\n    required this.progressVolumesMax,\n    required this.startedAt,\n    required this.completedAt,\n  });\n\n  factory BaseEntry(Map<String, dynamic> map) => BaseEntry._(\n    mediaId: map['id'],\n    entryId: map['mediaListEntry']?['id'],\n    isAnime: map['type'] == 'ANIME',\n    listStatus: ListStatus.from(map['mediaListEntry']?['status']),\n    progress: map['mediaListEntry']?['progress'] ?? 0,\n    progressMax: map['episodes'] ?? map['chapters'],\n    progressVolumes: map['mediaListEntry']?['progressVolumes'] ?? 0,\n    progressVolumesMax: map['volumes'],\n    startedAt: DateTimeExtension.fromFuzzyDate(map['mediaListEntry']?['startedAt']),\n    completedAt: DateTimeExtension.fromFuzzyDate(map['mediaListEntry']?['completedAt']),\n  );\n\n  final int mediaId;\n  final int? entryId;\n  final bool isAnime;\n  final ListStatus? listStatus;\n  final int progress;\n  final int? progressMax;\n  final int progressVolumes;\n  final int? progressVolumesMax;\n  final DateTime? startedAt;\n  final DateTime? completedAt;\n}\n"
  },
  {
    "path": "lib/feature/edit/edit_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/feature/collection/collection_provider.dart';\nimport 'package:otraku/feature/edit/edit_model.dart';\nimport 'package:otraku/feature/media/media_provider.dart';\nimport 'package:otraku/feature/settings/settings_provider.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal entryEditProvider = AsyncNotifierProvider.autoDispose\n    .family<EntryEditNotifier, EntryEdit, EditTag>(EntryEditNotifier.new);\n\nclass EntryEditNotifier extends AsyncNotifier<EntryEdit> {\n  EntryEditNotifier(this.arg);\n\n  final EditTag arg;\n\n  @override\n  FutureOr<EntryEdit> build() async {\n    if (ref.exists(mediaProvider(arg.id))) {\n      return ref.watch(mediaProvider(arg.id).selectAsync((s) => s.entryEdit));\n    }\n\n    final data = await ref.watch(repositoryProvider).request(GqlQuery.entry, {'mediaId': arg.id});\n\n    final settings = await ref.watch(settingsProvider.selectAsync((settings) => settings));\n\n    return EntryEdit(data['Media'], settings, arg.setComplete);\n  }\n\n  void updateBy(EntryEdit Function(EntryEdit) callback) => state = switch (state) {\n    AsyncData(:final value) => AsyncData(callback(value)),\n    _ => state,\n  };\n\n  Future<Object?> save() async {\n    final value = state.value;\n    if (value == null) return null;\n\n    state = const AsyncLoading();\n\n    final err = await ref\n        .read(repositoryProvider)\n        .request(GqlMutation.updateEntry, value.toGraphQlVariables())\n        .getErrorOrNull();\n\n    if (err != null) {\n      state = AsyncValue.data(value);\n      return err;\n    }\n\n    final viewerId = ref.read(viewerIdProvider);\n    if (viewerId == null) return null;\n\n    final tag = (userId: viewerId, ofAnime: value.baseEntry.isAnime);\n    ref\n        .read(collectionProvider(tag).notifier)\n        .saveEntry(value.baseEntry.mediaId, value.baseEntry.listStatus);\n\n    return null;\n  }\n\n  Future<Object?> remove() async {\n    final value = state.value;\n    if (value == null || value.baseEntry.entryId == null) return null;\n\n    state = const AsyncLoading();\n\n    final err = await ref.read(repositoryProvider).request(GqlMutation.removeEntry, {\n      'entryId': value.baseEntry.entryId,\n    }).getErrorOrNull();\n\n    if (err != null) {\n      state = AsyncValue.data(value);\n      return err;\n    }\n\n    final viewerId = ref.read(viewerIdProvider);\n    if (viewerId == null) return null;\n\n    final tag = (userId: viewerId, ofAnime: value.baseEntry.isAnime);\n    ref.read(collectionProvider(tag).notifier).removeEntry(value.baseEntry.mediaId);\n\n    return null;\n  }\n}\n"
  },
  {
    "path": "lib/feature/edit/edit_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/settings/settings_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/input/stateful_tiles.dart';\nimport 'package:otraku/widget/layout/navigation_tool.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/edit/edit_buttons.dart';\nimport 'package:otraku/feature/edit/edit_model.dart';\nimport 'package:otraku/feature/edit/edit_provider.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/feature/settings/settings_provider.dart';\nimport 'package:otraku/widget/input/date_field.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/input/number_field.dart';\nimport 'package:otraku/feature/edit/score_field.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\n\nclass EditView extends ConsumerWidget {\n  const EditView(this.tag, {this.callback});\n\n  final EditTag tag;\n  final void Function(EntryEdit)? callback;\n\n  @override\n  Widget build(BuildContext context, WidgetRef ref) {\n    final viewerId = ref.watch(viewerIdProvider);\n    if (viewerId == null) {\n      return SimpleSheet(\n        builder: (context, scrollCtrl) => const Center(\n          child: Padding(padding: Theming.paddingAll, child: Text('Log in to edit media')),\n        ),\n      );\n    }\n\n    return switch (ref.watch(entryEditProvider(tag))) {\n      AsyncData(:final value) => SheetWithButtonRow(\n        buttons: EditButtons(ref, tag, value, callback),\n        builder: (context, scrollCtrl) => _EditView(scrollCtrl, tag, value),\n      ),\n      AsyncError(:final error) => SheetWithButtonRow(\n        buttons: EditButtons(ref, tag, null, callback),\n        builder: (context, scrollCtrl) => Center(\n          child: Padding(\n            padding: Theming.paddingAll,\n            child: Text('Failed to load edit sheet: $error'),\n          ),\n        ),\n      ),\n      AsyncLoading() => SheetWithButtonRow(\n        buttons: EditButtons(ref, tag, null, callback),\n        builder: (context, scrollCtrl) => const Center(\n          child: Padding(padding: Theming.paddingAll, child: Loader()),\n        ),\n      ),\n    };\n  }\n}\n\nclass _EditView extends ConsumerWidget {\n  const _EditView(this.scrollCtrl, this.tag, this.entryEdit);\n\n  final ScrollController scrollCtrl;\n  final EditTag tag;\n  final EntryEdit entryEdit;\n\n  @override\n  Widget build(BuildContext context, WidgetRef ref) {\n    final readableNotifier = entryEditProvider(tag).notifier;\n\n    final settings = ref.watch(settingsProvider.select((s) => s.value));\n    final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast));\n\n    final statusField = SliverToBoxAdapter(\n      child: Padding(\n        padding: const .symmetric(horizontal: Theming.offset),\n        child: ChipSelector(\n          title: 'Status',\n          items: ListStatus.values.map((v) => (v.label(entryEdit.baseEntry.isAnime), v)).toList(),\n          value: entryEdit.listStatus,\n          highContrast: highContrast,\n          onChanged: (status) => ref.read(readableNotifier).updateBy((s) {\n            var startedAt = s.startedAt;\n            var completedAt = s.completedAt;\n            var progress = s.progress;\n\n            if (entryEdit.baseEntry.listStatus == null && status == .current && startedAt == null) {\n              startedAt = DateTime.now();\n              SnackBarExtension.show(context, 'Start date changed');\n            } else if (entryEdit.baseEntry.listStatus != status &&\n                status == .completed &&\n                completedAt == null) {\n              completedAt = DateTime.now();\n              var text = 'Completed date changed';\n\n              if (entryEdit.baseEntry.progressMax != null && progress < s.baseEntry.progressMax!) {\n                progress = s.baseEntry.progressMax!;\n                text = 'Completed date & progress changed';\n              }\n\n              SnackBarExtension.show(context, text);\n            }\n\n            return s.copyWith(\n              listStatus: status,\n              progress: progress,\n              startedAt: (startedAt,),\n              completedAt: (completedAt,),\n            );\n          }),\n        ),\n      ),\n    );\n\n    final timelineFields = _FieldGrid(\n      minWidth: 195,\n      children: [\n        DateField(\n          label: 'Started',\n          value: entryEdit.startedAt,\n          onChanged: (startedAt) => ref.read(readableNotifier).updateBy((s) {\n            var listStatus = s.listStatus;\n\n            if (startedAt != null && entryEdit.baseEntry.listStatus == null && listStatus == null) {\n              listStatus = .current;\n              SnackBarExtension.show(context, 'Status changed');\n            }\n\n            return s.copyWith(listStatus: listStatus, startedAt: (startedAt,));\n          }),\n        ),\n        DateField(\n          label: 'Completed',\n          value: entryEdit.completedAt,\n          onChanged: (completedAt) => ref.read(readableNotifier).updateBy((s) {\n            var listStatus = s.listStatus;\n            var progress = s.progress;\n\n            if (completedAt != null &&\n                entryEdit.baseEntry.listStatus != .completed &&\n                entryEdit.baseEntry.listStatus != .repeating &&\n                entryEdit.baseEntry.listStatus == listStatus) {\n              listStatus = .completed;\n              String text = 'Status changed';\n\n              if (s.baseEntry.progressMax != null && s.progress < s.baseEntry.progressMax!) {\n                progress = s.baseEntry.progressMax!;\n                text = 'Status & progress changed';\n              }\n\n              SnackBarExtension.show(context, text);\n            }\n\n            return s.copyWith(\n              listStatus: listStatus,\n              progress: progress,\n              completedAt: (completedAt,),\n            );\n          }),\n        ),\n        NumberField(\n          label: 'Repeat',\n          value: entryEdit.repeat,\n          onChanged: (repeat) => entryEdit.repeat = repeat,\n        ),\n      ],\n    );\n\n    return Material(\n      color: Colors.transparent,\n      child: CustomScrollView(\n        controller: scrollCtrl,\n        slivers: [\n          const SliverToBoxAdapter(child: SizedBox(height: 20)),\n          statusField,\n          const SliverToBoxAdapter(child: SizedBox(height: 15)),\n          _buildProgressFields(context, ref),\n          SliverToBoxAdapter(\n            child: ScoreField(\n              value: entryEdit.score,\n              scoreFormat: settings?.scoreFormat,\n              onChanged: (score) => entryEdit.score = score,\n            ),\n          ),\n          const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)),\n          _buildAdvancedScoringFields(ref, settings),\n          const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)),\n          _Notes(value: entryEdit.notes, onChanged: (notes) => entryEdit.notes = notes),\n          const SliverToBoxAdapter(child: SizedBox(height: 20)),\n          timelineFields,\n          SliverToBoxAdapter(\n            child: StatefulCheckboxListTile(\n              title: const Text('Private'),\n              value: entryEdit.private,\n              onChanged: (private) => entryEdit.private = private!,\n            ),\n          ),\n          SliverToBoxAdapter(\n            child: StatefulCheckboxListTile(\n              title: const Text('Hidden From Status Lists'),\n              value: entryEdit.hiddenFromStatusLists,\n              onChanged: (hiddenFromStatusLists) =>\n                  entryEdit.hiddenFromStatusLists = hiddenFromStatusLists!,\n            ),\n          ),\n          if (entryEdit.customLists.isNotEmpty)\n            SliverToBoxAdapter(\n              child: ExpansionTile(\n                title: const Text('Custom Lists'),\n                initiallyExpanded: true,\n                children: [\n                  for (final e in entryEdit.customLists.entries)\n                    StatefulCheckboxListTile(\n                      title: Text(e.key),\n                      value: e.value,\n                      onChanged: (v) => entryEdit.customLists[e.key] = v!,\n                    ),\n                ],\n              ),\n            ),\n          SliverToBoxAdapter(\n            child: SizedBox(height: MediaQuery.paddingOf(context).bottom + BottomBar.height + 10),\n          ),\n        ],\n      ),\n    );\n  }\n\n  Widget _buildProgressFields(BuildContext context, WidgetRef ref) {\n    final readableNotifier = entryEditProvider(tag).notifier;\n\n    final progressField = NumberField(\n      label: 'Progress',\n      value: entryEdit.progress,\n      maxValue: entryEdit.baseEntry.progressMax ?? 100000,\n      onChanged: (progress) => ref.read(readableNotifier).updateBy((s) {\n        var status = s.listStatus;\n        var startedAt = s.startedAt;\n        var completedAt = s.completedAt;\n\n        String? text;\n        if (progress == entryEdit.baseEntry.progressMax &&\n            progress != entryEdit.baseEntry.progress) {\n          if (entryEdit.baseEntry.listStatus == status && status != .completed) {\n            status = .completed;\n            text = 'Status changed';\n          }\n\n          if (entryEdit.baseEntry.completedAt == null && completedAt == null) {\n            completedAt = DateTime.now();\n            text = text == null ? 'Completed date changed' : 'Status & Completed date changed';\n          }\n        } else if (entryEdit.baseEntry.progress == 0 && entryEdit.baseEntry.progress != progress) {\n          if (entryEdit.baseEntry.listStatus == status && (status == null || status == .planning)) {\n            status = .current;\n            text = 'Status changed';\n          }\n\n          if (entryEdit.baseEntry.startedAt == null && startedAt == null) {\n            startedAt = DateTime.now();\n            text = text == null ? 'Start date changed' : 'Status & start date changed';\n          }\n        }\n\n        if (text != null) SnackBarExtension.show(context, text);\n\n        return s.copyWith(\n          progress: progress,\n          listStatus: status,\n          startedAt: (startedAt,),\n          completedAt: (completedAt,),\n        );\n      }),\n    );\n\n    Widget child = progressField;\n\n    if (!entryEdit.baseEntry.isAnime) {\n      final volumeProgressField = NumberField(\n        label: 'Volume Progress',\n        value: entryEdit.progressVolumes,\n        maxValue: entryEdit.baseEntry.progressVolumesMax ?? 100000,\n        onChanged: (progressVolumes) => entryEdit.progressVolumes = progressVolumes,\n      );\n\n      child = MediaQuery.sizeOf(context).width < Theming.windowWidthMedium\n          ? Column(\n              mainAxisSize: .min,\n              children: [progressField, const SizedBox(height: 20), volumeProgressField],\n            )\n          : Row(\n              children: Theming.of(context).rightButtonOrientation\n                  ? [\n                      Expanded(child: volumeProgressField),\n                      const SizedBox(width: Theming.offset),\n                      Expanded(child: progressField),\n                    ]\n                  : [\n                      Expanded(child: progressField),\n                      const SizedBox(width: Theming.offset),\n                      Expanded(child: volumeProgressField),\n                    ],\n            );\n    }\n\n    return SliverPadding(\n      padding: const .only(left: Theming.offset, right: Theming.offset, bottom: Theming.offset),\n      sliver: SliverToBoxAdapter(child: child),\n    );\n  }\n\n  Widget _buildAdvancedScoringFields(WidgetRef ref, Settings? settings) {\n    final advancedScoringEnabled = settings?.advancedScoringEnabled ?? false;\n    final scoreFormat = settings?.scoreFormat ?? .point10;\n\n    if (!advancedScoringEnabled || scoreFormat != .point100 && scoreFormat != .point10Decimal) {\n      return const SliverToBoxAdapter(child: SizedBox());\n    }\n\n    final scores = entryEdit.advancedScores;\n    final isDecimal = scoreFormat == .point10Decimal;\n\n    final onChanged = (entry, score) {\n      scores[entry.key] = score.toDouble();\n\n      int count = 0;\n      double avg = 0;\n      for (final v in scores.values) {\n        if (v > 0) {\n          avg += v;\n          count++;\n        }\n      }\n\n      if (count > 0) avg /= count;\n\n      if (entryEdit.score != avg) {\n        ref.read(entryEditProvider(tag).notifier).updateBy((s) => s.copyWith(score: avg));\n      }\n    };\n\n    return _FieldGrid(\n      minWidth: 140,\n      children: [\n        for (final s in scores.entries)\n          isDecimal\n              ? NumberField.decimal(\n                  label: s.key,\n                  value: s.value,\n                  maxValue: 10.0,\n                  onChanged: (score) => onChanged(s, score),\n                )\n              : NumberField(\n                  label: s.key,\n                  value: s.value.toInt(),\n                  maxValue: 100,\n                  onChanged: (score) => onChanged(s, score),\n                ),\n      ],\n    );\n  }\n}\n\nclass _FieldGrid extends StatelessWidget {\n  const _FieldGrid({required this.minWidth, required this.children});\n\n  final List<Widget> children;\n  final double minWidth;\n\n  @override\n  Widget build(BuildContext context) {\n    return SliverPadding(\n      padding: const .symmetric(horizontal: Theming.offset),\n      sliver: SliverGrid(\n        delegate: SliverChildListDelegate.fixed(children),\n        gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: minWidth, height: 58),\n      ),\n    );\n  }\n}\n\nclass _Notes extends StatefulWidget {\n  const _Notes({required this.value, required this.onChanged});\n\n  final String value;\n  final void Function(String) onChanged;\n\n  @override\n  _NotesState createState() => _NotesState();\n}\n\nclass _NotesState extends State<_Notes> {\n  late final _ctrl = TextEditingController(text: widget.value);\n\n  @override\n  void dispose() {\n    _ctrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) => SliverToBoxAdapter(\n    child: Padding(\n      padding: const .symmetric(horizontal: Theming.offset),\n      child: TextField(\n        minLines: 1,\n        maxLines: 10,\n        controller: _ctrl,\n        style: TextTheme.of(context).bodyMedium,\n        decoration: InputDecoration(\n          labelText: 'Notes',\n          labelStyle: TextTheme.of(context).bodyMedium,\n          border: const OutlineInputBorder(),\n        ),\n        onChanged: (value) => widget.onChanged(value),\n      ),\n    ),\n  );\n}\n"
  },
  {
    "path": "lib/feature/edit/score_field.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/util/theming.dart';\n\n/// Score picker.\nclass ScoreField extends StatefulWidget {\n  const ScoreField({required this.value, required this.scoreFormat, required this.onChanged});\n\n  final double value;\n  final ScoreFormat? scoreFormat;\n  final void Function(double) onChanged;\n\n  @override\n  State<ScoreField> createState() => _ScoreFieldState();\n}\n\nclass _ScoreFieldState extends State<ScoreField> {\n  late var _value = widget.value;\n\n  @override\n  void didUpdateWidget(covariant ScoreField oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _value = widget.value;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return Padding(\n      padding: const .all(Theming.offset),\n      child: InputDecorator(\n        decoration: const InputDecoration(labelText: 'Score', border: OutlineInputBorder()),\n        child: switch (widget.scoreFormat ?? .point10) {\n          .point3 => _SmileyScorePicker(_value, _onChanged),\n          .point5 => _StarScorePicker(_value, _onChanged),\n          .point10 => _TenScorePicker(_value, _onChanged),\n          .point10Decimal => _TenDecimalScorePicker(_value, _onChanged),\n          .point100 => _HundredScorePicker(_value, _onChanged),\n        },\n      ),\n    );\n  }\n\n  void _onChanged(double value) {\n    setState(() => _value = value);\n    widget.onChanged(value);\n  }\n}\n\nclass _SmileyScorePicker extends StatelessWidget {\n  const _SmileyScorePicker(this.score, this.onChanged);\n\n  final double score;\n  final void Function(double) onChanged;\n\n  @override\n  Widget build(BuildContext context) {\n    const items = [\n      (1, Icon(Icons.sentiment_very_dissatisfied), 'Score Disliked'),\n      (2, Icon(Icons.sentiment_neutral), 'Score Neutral'),\n      (3, Icon(Icons.sentiment_very_satisfied), 'Score Liked'),\n    ];\n\n    return Row(\n      mainAxisAlignment: .spaceEvenly,\n      children: [\n        for (final (i, icon, tooltip) in items)\n          IconButton(\n            tooltip: score.floor() != i ? tooltip : 'Unscore',\n            iconSize: 30,\n            icon: icon,\n            color: score.floor() != i\n                ? ColorScheme.of(context).surfaceContainerHighest\n                : ColorScheme.of(context).primary,\n            onPressed: () => score.floor() != i ? onChanged(i.toDouble()) : onChanged(0),\n          ),\n      ],\n    );\n  }\n}\n\nclass _StarScorePicker extends StatelessWidget {\n  const _StarScorePicker(this.score, this.onChanged);\n\n  final double score;\n  final void Function(double) onChanged;\n\n  @override\n  Widget build(BuildContext context) {\n    return Row(\n      mainAxisAlignment: .spaceEvenly,\n      children: [\n        for (int i = 1; i < 6; i++)\n          IconButton(\n            tooltip: score.floor() != i ? 'Score $i Stars' : 'Unscore',\n            iconSize: 30,\n            icon: score >= i\n                ? const Icon(Icons.star_rounded)\n                : const Icon(Icons.star_outline_rounded),\n            color: ColorScheme.of(context).primary,\n            onPressed: () => score.floor() != i ? onChanged(i.toDouble()) : onChanged(0),\n          ),\n      ],\n    );\n  }\n}\n\nclass _TenScorePicker extends StatelessWidget {\n  const _TenScorePicker(this.score, this.onChanged);\n\n  final double score;\n  final void Function(double) onChanged;\n\n  @override\n  Widget build(BuildContext context) {\n    return Row(\n      children: [\n        Expanded(\n          child: Slider.adaptive(\n            value: score.truncateToDouble(),\n            onChanged: onChanged,\n            min: 0,\n            max: 10,\n            divisions: 10,\n          ),\n        ),\n        SizedBox(width: 30, child: Text(score.toStringAsFixed(0))),\n      ],\n    );\n  }\n}\n\nclass _TenDecimalScorePicker extends StatelessWidget {\n  const _TenDecimalScorePicker(this.score, this.onChanged);\n\n  final double score;\n  final void Function(double) onChanged;\n\n  @override\n  Widget build(BuildContext context) {\n    return Row(\n      children: [\n        Expanded(\n          child: Slider.adaptive(\n            value: score,\n            onChanged: (v) => onChanged((v * 10).round() / 10),\n            min: 0,\n            max: 10,\n            divisions: 100,\n          ),\n        ),\n        SizedBox(width: 40, child: Text(score.toStringAsFixed(1))),\n      ],\n    );\n  }\n}\n\nclass _HundredScorePicker extends StatelessWidget {\n  const _HundredScorePicker(this.score, this.onChanged);\n\n  final double score;\n  final void Function(double) onChanged;\n\n  @override\n  Widget build(BuildContext context) {\n    return Row(\n      children: [\n        Expanded(\n          child: Slider.adaptive(\n            value: score,\n            onChanged: onChanged,\n            min: 0,\n            max: 100,\n            divisions: 100,\n          ),\n        ),\n        SizedBox(width: 30, child: Text(score.toStringAsFixed(0))),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/favorites/favorites_model.dart",
    "content": "import 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/util/paged.dart';\n\nclass Favorites {\n  const Favorites({\n    this.anime = const PagedWithTotal(),\n    this.manga = const PagedWithTotal(),\n    this.characters = const PagedWithTotal(),\n    this.staff = const PagedWithTotal(),\n    this.studios = const PagedWithTotal(),\n    this.edit,\n  });\n\n  final PagedWithTotal<FavoriteItem> anime;\n  final PagedWithTotal<FavoriteItem> manga;\n  final PagedWithTotal<FavoriteItem> characters;\n  final PagedWithTotal<FavoriteItem> staff;\n  final PagedWithTotal<FavoriteItem> studios;\n  final FavoritesEdit? edit;\n\n  int getCount(FavoritesType type) => switch (type) {\n    .anime => anime.total,\n    .manga => manga.total,\n    .characters => characters.total,\n    .staff => staff.total,\n    .studios => studios.total,\n  };\n\n  Favorites withEdit(FavoritesEdit? edit) => Favorites(\n    anime: anime,\n    manga: manga,\n    characters: characters,\n    staff: staff,\n    studios: studios,\n    edit: edit,\n  );\n}\n\nclass FavoritesEdit {\n  const FavoritesEdit(this.editedType, this.oldItems);\n\n  /// The favorites category that is currently being edited.\n  final FavoritesType editedType;\n\n  /// The favorite items from the category in their original sorting.\n  final List<FavoriteItem> oldItems;\n}\n\nclass FavoriteItem {\n  FavoriteItem._({required this.id, required this.name, required this.imageUrl})\n    : isFavorite = true;\n\n  factory FavoriteItem.media(Map<String, dynamic> map, ImageQuality imageQuality) => FavoriteItem._(\n    id: map['id'],\n    name: map['title']['userPreferred'],\n    imageUrl: map['coverImage'][imageQuality.value],\n  );\n\n  factory FavoriteItem.character(Map<String, dynamic> map) => FavoriteItem._(\n    id: map['id'],\n    name: map['name']['userPreferred'],\n    imageUrl: map['image']['large'],\n  );\n\n  factory FavoriteItem.staff(Map<String, dynamic> map) => FavoriteItem._(\n    id: map['id'],\n    name: map['name']['userPreferred'],\n    imageUrl: map['image']['large'],\n  );\n\n  factory FavoriteItem.studio(Map<String, dynamic> map) =>\n      FavoriteItem._(id: map['id'], name: map['name'], imageUrl: null);\n\n  final int id;\n  final String name;\n  final String? imageUrl;\n  bool isFavorite;\n}\n\nenum FavoritesType {\n  anime,\n  manga,\n  characters,\n  staff,\n  studios;\n\n  String get title => switch (this) {\n    .anime => 'Favourite Anime',\n    .manga => 'Favourite Manga',\n    .characters => 'Favourite Characters',\n    .staff => 'Favourite Staff',\n    .studios => 'Favourite Studios',\n  };\n}\n"
  },
  {
    "path": "lib/feature/favorites/favorites_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/favorites/favorites_model.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal favoritesProvider = AsyncNotifierProvider.autoDispose\n    .family<FavoritesNotifier, Favorites, int>(FavoritesNotifier.new);\n\nclass FavoritesNotifier extends AsyncNotifier<Favorites> {\n  FavoritesNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Favorites> build() => _fetch(const Favorites(), null);\n\n  Future<void> fetch(FavoritesType type) async {\n    final oldState = state.value ?? const Favorites();\n    switch (type) {\n      case .anime:\n        if (!oldState.anime.hasNext) return;\n      case .manga:\n        if (!oldState.manga.hasNext) return;\n      case .characters:\n        if (!oldState.characters.hasNext) return;\n      case .staff:\n        if (!oldState.staff.hasNext) return;\n      case .studios:\n        if (!oldState.studios.hasNext) return;\n    }\n    state = await AsyncValue.guard(() => _fetch(oldState, type));\n  }\n\n  Future<Favorites> _fetch(Favorites oldState, FavoritesType? type) async {\n    final edit = oldState.edit;\n    final variables = <String, dynamic>{'userId': arg};\n\n    if (type == null) {\n      variables['withAnime'] = true;\n      variables['withManga'] = true;\n      variables['withCharacters'] = true;\n      variables['withStaff'] = true;\n      variables['withStudios'] = true;\n    } else if (type == .anime) {\n      variables['withAnime'] = true;\n      variables['page'] = oldState.anime.next;\n    } else if (type == .manga) {\n      variables['withManga'] = true;\n      variables['page'] = oldState.manga.next;\n    } else if (type == .characters) {\n      variables['withCharacters'] = true;\n      variables['page'] = oldState.characters.next;\n    } else if (type == .staff) {\n      variables['withStaff'] = true;\n      variables['page'] = oldState.staff.next;\n    } else {\n      variables['withStudios'] = true;\n      variables['page'] = oldState.studios.next;\n    }\n\n    var data = await ref.read(repositoryProvider).request(GqlQuery.favorites, variables);\n    data = data['User']['favourites'];\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    var anime = oldState.anime;\n    var manga = oldState.manga;\n    var characters = oldState.characters;\n    var staff = oldState.staff;\n    var studios = oldState.studios;\n\n    if (type == null || type == .anime) {\n      final map = data['anime'];\n      final items = <FavoriteItem>[];\n      for (final a in map['nodes']) {\n        items.add(FavoriteItem.media(a, imageQuality));\n      }\n\n      anime = anime.withNext(\n        items,\n        map['pageInfo']['hasNextPage'] ?? false,\n        map['pageInfo']['total'],\n      );\n\n      if (edit?.editedType == .anime) {\n        edit!.oldItems.addAll(items);\n      }\n    }\n\n    if (type == null || type == .manga) {\n      final map = data['manga'];\n      final items = <FavoriteItem>[];\n      for (final m in map['nodes']) {\n        items.add(FavoriteItem.media(m, imageQuality));\n      }\n\n      manga = manga.withNext(\n        items,\n        map['pageInfo']['hasNextPage'] ?? false,\n        map['pageInfo']['total'],\n      );\n\n      if (edit?.editedType == .manga) {\n        edit!.oldItems.addAll(items);\n      }\n    }\n\n    if (type == null || type == .characters) {\n      final map = data['characters'];\n      final items = <FavoriteItem>[];\n      for (final c in map['nodes']) {\n        items.add(FavoriteItem.character(c));\n      }\n\n      characters = characters.withNext(\n        items,\n        map['pageInfo']['hasNextPage'] ?? false,\n        map['pageInfo']['total'],\n      );\n\n      if (edit?.editedType == .characters) {\n        edit!.oldItems.addAll(items);\n      }\n    }\n\n    if (type == null || type == .staff) {\n      final map = data['staff'];\n      final items = <FavoriteItem>[];\n      for (final s in map['nodes']) {\n        items.add(FavoriteItem.staff(s));\n      }\n\n      staff = staff.withNext(\n        items,\n        map['pageInfo']['hasNextPage'] ?? false,\n        map['pageInfo']['total'],\n      );\n\n      if (edit?.editedType == .staff) {\n        edit!.oldItems.addAll(items);\n      }\n    }\n\n    if (type == null || type == .studios) {\n      final map = data['studios'];\n      final items = <FavoriteItem>[];\n      for (final s in map['nodes']) {\n        items.add(FavoriteItem.studio(s));\n      }\n\n      studios = studios.withNext(\n        items,\n        map['pageInfo']['hasNextPage'] ?? false,\n        map['pageInfo']['total'],\n      );\n\n      if (edit?.editedType == .studios) {\n        edit!.oldItems.addAll(items);\n      }\n    }\n\n    return Favorites(\n      anime: anime,\n      manga: manga,\n      characters: characters,\n      staff: staff,\n      studios: studios,\n      edit: edit,\n    );\n  }\n\n  void startEdit(FavoritesType type) {\n    final value = state.value;\n    if (value == null) return;\n\n    final edit = FavoritesEdit(type, switch (type) {\n      .anime => [...value.anime.items],\n      .manga => [...value.manga.items],\n      .characters => [...value.characters.items],\n      .staff => [...value.staff.items],\n      .studios => [...value.studios.items],\n    });\n\n    state = AsyncValue.data(value.withEdit(edit));\n  }\n\n  void cancelEdit() {\n    final value = state.value;\n    if (value == null) return;\n\n    final edit = value.edit;\n    if (edit == null) return;\n\n    switch (edit.editedType) {\n      case .anime:\n        value.anime.items.clear();\n        value.anime.items.addAll(edit.oldItems);\n      case .manga:\n        value.manga.items.clear();\n        value.manga.items.addAll(edit.oldItems);\n      case .characters:\n        value.characters.items.clear();\n        value.characters.items.addAll(edit.oldItems);\n      case .staff:\n        value.staff.items.clear();\n        value.staff.items.addAll(edit.oldItems);\n      case .studios:\n        value.studios.items.clear();\n        value.studios.items.addAll(edit.oldItems);\n    }\n\n    state = AsyncValue.data(value.withEdit(null));\n  }\n\n  Future<Object?> saveEdit() async {\n    final value = state.value;\n    if (value == null) return null;\n\n    final edit = value.edit;\n    if (edit == null) return null;\n\n    state = AsyncValue.data(value.withEdit(null));\n\n    String idsVariableKey;\n    String indexesVariableKey;\n    List<FavoriteItem> items;\n    switch (edit.editedType) {\n      case .anime:\n        idsVariableKey = 'animeIds';\n        indexesVariableKey = 'animeOrder';\n        items = value.anime.items;\n      case .manga:\n        idsVariableKey = 'mangaIds';\n        indexesVariableKey = 'mangaOrder';\n        items = value.manga.items;\n      case .characters:\n        idsVariableKey = 'characterIds';\n        indexesVariableKey = 'characterOrder';\n        items = value.characters.items;\n      case .staff:\n        idsVariableKey = 'staffIds';\n        indexesVariableKey = 'staffOrder';\n        items = value.staff.items;\n      case .studios:\n        idsVariableKey = 'studioIds';\n        indexesVariableKey = 'studioOrder';\n        items = value.studios.items;\n    }\n\n    final ids = items.map((e) => e.id).toList();\n    final indexes = List.generate(items.length, (i) => i + 1, growable: false);\n\n    final err = await ref.read(repositoryProvider).request(GqlMutation.reorderFavorites, {\n      idsVariableKey: ids,\n      indexesVariableKey: indexes,\n    }).getErrorOrNull();\n\n    if (err != null) cancelEdit();\n    return err;\n  }\n\n  Future<Object?> toggleFavorite(int id) async {\n    final edit = state.value?.edit;\n    if (edit == null) return null;\n\n    final typeKey = switch (edit.editedType) {\n      .anime => 'anime',\n      .manga => 'manga',\n      .characters => 'character',\n      .staff => 'staff',\n      .studios => 'studio',\n    };\n\n    return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, {\n      typeKey: id,\n    }).getErrorOrNull();\n  }\n}\n"
  },
  {
    "path": "lib/feature/favorites/favorites_view.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/extension/scroll_controller_extension.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/edit/edit_view.dart';\nimport 'package:otraku/feature/favorites/favorites_model.dart';\nimport 'package:otraku/feature/favorites/favorites_provider.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass FavoritesView extends ConsumerStatefulWidget {\n  const FavoritesView(this.userId);\n\n  final int userId;\n\n  @override\n  ConsumerState<FavoritesView> createState() => _FavoritesViewState();\n}\n\nclass _FavoritesViewState extends ConsumerState<FavoritesView> with SingleTickerProviderStateMixin {\n  late final _tabCtrl = TabController(length: FavoritesType.values.length, vsync: this);\n  late final _scrollCtrl = PagedController(\n    loadMore: () => ref\n        .read(favoritesProvider(widget.userId).notifier)\n        .fetch(FavoritesType.values[_tabCtrl.index]),\n  );\n\n  @override\n  void initState() {\n    super.initState();\n    _tabCtrl.addListener(() => setState(() {}));\n  }\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final type = FavoritesType.values[_tabCtrl.index];\n\n    final isViewer = ref.watch(viewerIdProvider) == widget.userId;\n\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    final count = ref.watch(\n      favoritesProvider(widget.userId).select((s) => s.value?.getCount(type) ?? 0),\n    );\n\n    final onRefresh = (invalidate) => invalidate(favoritesProvider(widget.userId));\n\n    final toggleFavorite = (int itemId) =>\n        ref.read(favoritesProvider(widget.userId).notifier).toggleFavorite(itemId);\n\n    final inEditingMode = ref.watch(\n      favoritesProvider(widget.userId).select((s) => s.value?.edit != null),\n    );\n\n    return AdaptiveScaffold(\n      topBar: TopBarAnimatedSwitcher(\n        TopBar(\n          key: inEditingMode ? const Key('EditTopBar') : Key('${type.title}TopBar'),\n          title: type.title,\n          trailing: [\n            if (inEditingMode) ...[\n              IconButton(\n                tooltip: 'Cancel',\n                icon: const Icon(Icons.close_rounded),\n                onPressed: () => ref.read(favoritesProvider(widget.userId).notifier).cancelEdit(),\n              ),\n              IconButton(\n                tooltip: 'Save',\n                icon: const Icon(Icons.save_outlined),\n                onPressed: () =>\n                    ref.read(favoritesProvider(widget.userId).notifier).saveEdit().then((err) {\n                      if (err == null || !context.mounted) return;\n\n                      SnackBarExtension.show(context, 'Failed to reorder: $err');\n                    }),\n              ),\n            ] else if (count > 0)\n              Padding(\n                padding: const .only(right: Theming.offset),\n                child: Text(count.toString(), style: TextTheme.of(context).titleSmall),\n              ),\n          ],\n        ),\n      ),\n      floatingAction: !isViewer || inEditingMode\n          ? null\n          : HidingFloatingActionButton(\n              key: const Key('edit'),\n              scrollCtrl: _scrollCtrl,\n              child: FloatingActionButton(\n                tooltip: 'Edit',\n                child: const Icon(Icons.edit_outlined),\n                onPressed: () =>\n                    ref.read(favoritesProvider(widget.userId).notifier).startEdit(type),\n              ),\n            ),\n      navigationConfig: inEditingMode\n          ? null\n          : NavigationConfig(\n              selected: _tabCtrl.index,\n              onChanged: (i) => _tabCtrl.index = i,\n              onSame: (_) => _scrollCtrl.scrollToTop(),\n              items: const {\n                'Anime': Ionicons.film_outline,\n                'Manga': Ionicons.book_outline,\n                'Characters': Ionicons.man_outline,\n                'Staff': Ionicons.briefcase_outline,\n                'Studios': Ionicons.business_outline,\n              },\n            ),\n      child: AnimatedSwitcher(\n        switchInCurve: Curves.easeOut,\n        duration: const Duration(milliseconds: 200),\n        reverseDuration: const Duration(seconds: 0),\n        transitionBuilder: (child, animation) => SlideTransition(\n          position: Tween(begin: const Offset(0, 0.05), end: Offset.zero).animate(animation),\n          child: child,\n        ),\n        child: TabBarView(\n          key: inEditingMode ? const Key('editTabBarView') : const Key('tabBarView'),\n          controller: _tabCtrl,\n          children: [\n            PagedView<FavoriteItem>(\n              provider: favoritesProvider(\n                widget.userId,\n              ).select((s) => s.unwrapPrevious().whenData((data) => data.anime)),\n              scrollCtrl: _scrollCtrl,\n              onRefresh: onRefresh,\n              onData: (data) {\n                final onTapItem = (FavoriteItem item) =>\n                    context.push(Routes.media(item.id, item.imageUrl));\n                final onLongTapItem = (FavoriteItem item) =>\n                    showSheet(context, EditView((id: item.id, setComplete: false)));\n\n                return inEditingMode\n                    ? _EditList(\n                        data.items,\n                        onTapItem,\n                        onLongTapItem,\n                        toggleFavorite,\n                        options.highContrast,\n                      )\n                    : _ImageGrid(data.items, onTapItem, onLongTapItem, options.highContrast);\n              },\n            ),\n            PagedView<FavoriteItem>(\n              provider: favoritesProvider(\n                widget.userId,\n              ).select((s) => s.unwrapPrevious().whenData((data) => data.manga)),\n              scrollCtrl: _scrollCtrl,\n              onRefresh: onRefresh,\n              onData: (data) {\n                final onTapItem = (FavoriteItem item) =>\n                    context.push(Routes.media(item.id, item.imageUrl));\n                final onLongTapItem = (FavoriteItem item) =>\n                    showSheet(context, EditView((id: item.id, setComplete: false)));\n\n                return inEditingMode\n                    ? _EditList(\n                        data.items,\n                        onTapItem,\n                        onLongTapItem,\n                        toggleFavorite,\n                        options.highContrast,\n                      )\n                    : _ImageGrid(data.items, onTapItem, onLongTapItem, options.highContrast);\n              },\n            ),\n            PagedView<FavoriteItem>(\n              provider: favoritesProvider(\n                widget.userId,\n              ).select((s) => s.unwrapPrevious().whenData((data) => data.characters)),\n              scrollCtrl: _scrollCtrl,\n              onRefresh: onRefresh,\n              onData: (data) {\n                final onTapItem = (FavoriteItem item) =>\n                    context.push(Routes.character(item.id, item.imageUrl));\n\n                return inEditingMode\n                    ? _EditList(data.items, onTapItem, null, toggleFavorite, options.highContrast)\n                    : _ImageGrid(data.items, onTapItem, null, options.highContrast);\n              },\n            ),\n            PagedView<FavoriteItem>(\n              provider: favoritesProvider(\n                widget.userId,\n              ).select((s) => s.unwrapPrevious().whenData((data) => data.staff)),\n              scrollCtrl: _scrollCtrl,\n              onRefresh: onRefresh,\n              onData: (data) {\n                final onTapItem = (FavoriteItem item) =>\n                    context.push(Routes.staff(item.id, item.imageUrl));\n\n                return inEditingMode\n                    ? _EditList(data.items, onTapItem, null, toggleFavorite, options.highContrast)\n                    : _ImageGrid(data.items, onTapItem, null, options.highContrast);\n              },\n            ),\n            PagedView<FavoriteItem>(\n              provider: favoritesProvider(\n                widget.userId,\n              ).select((s) => s.unwrapPrevious().whenData((data) => data.studios)),\n              scrollCtrl: _scrollCtrl,\n              onRefresh: onRefresh,\n              onData: (data) {\n                final onTapItem = (FavoriteItem item) =>\n                    context.push(Routes.studio(item.id, item.imageUrl));\n\n                return inEditingMode\n                    ? _EditList(\n                        data.items,\n                        onTapItem,\n                        null,\n                        toggleFavorite,\n                        options.highContrast,\n                        compact: true,\n                      )\n                    : _TextGrid(data.items, onTapItem, options.highContrast);\n              },\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n\nclass _ImageGrid extends StatefulWidget {\n  const _ImageGrid(this.items, this.onTapItem, this.onLongTapItem, this.highContrast);\n\n  final List<FavoriteItem> items;\n  final void Function(FavoriteItem) onTapItem;\n  final void Function(FavoriteItem)? onLongTapItem;\n  final bool highContrast;\n\n  @override\n  State<_ImageGrid> createState() => _ImageGridState();\n}\n\nclass _ImageGridState extends State<_ImageGrid> {\n  late List<FavoriteItem> _items;\n\n  @override\n  void initState() {\n    super.initState();\n    _items = widget.items.where((e) => e.isFavorite).toList();\n  }\n\n  @override\n  void didUpdateWidget(covariant _ImageGrid oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    if (widget.items != oldWidget.items) {\n      _items = widget.items.where((e) => e.isFavorite).toList();\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final textHeight = lineHeight * 2 + 10;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight(\n        minWidth: 100,\n        extraHeight: textHeight,\n        rawHWRatio: Theming.coverHtoWRatio,\n      ),\n      delegate: SliverChildBuilderDelegate(\n        childCount: _items.length,\n        (_, i) => InkWell(\n          borderRadius: Theming.borderRadiusSmall,\n          onTap: () => widget.onTapItem(_items[i]),\n          onLongPress: () => widget.onLongTapItem?.call(_items[i]),\n          child: CardExtension.highContrast(widget.highContrast)(\n            child: Column(\n              crossAxisAlignment: .stretch,\n              children: [\n                if (_items[i].imageUrl != null)\n                  Expanded(\n                    child: Hero(\n                      tag: _items[i].id,\n                      child: ClipRRect(\n                        borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall),\n                        child: CachedImage(_items[i].imageUrl!),\n                      ),\n                    ),\n                  ),\n                SizedBox(\n                  height: textHeight,\n                  child: Padding(\n                    padding: const .all(5),\n                    child: Text(_items[i].name, maxLines: 2, overflow: .ellipsis),\n                  ),\n                ),\n              ],\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass _TextGrid extends StatefulWidget {\n  const _TextGrid(this.items, this.onTapItem, this.highContrast);\n\n  final List<FavoriteItem> items;\n  final void Function(FavoriteItem) onTapItem;\n  final bool highContrast;\n\n  @override\n  State<_TextGrid> createState() => _TextGridState();\n}\n\nclass _TextGridState extends State<_TextGrid> {\n  late List<FavoriteItem> _items;\n\n  @override\n  void initState() {\n    super.initState();\n    _items = widget.items.where((e) => e.isFavorite).toList();\n  }\n\n  @override\n  void didUpdateWidget(covariant _TextGrid oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    if (widget.items != oldWidget.items) {\n      _items = widget.items.where((e) => e.isFavorite).toList();\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(\n        minWidth: 230,\n        height: lineHeight + 20,\n        mainAxisSpacing: 10,\n        crossAxisSpacing: 10,\n      ),\n      delegate: SliverChildBuilderDelegate(\n        childCount: _items.length,\n        (_, i) => InkWell(\n          borderRadius: Theming.borderRadiusSmall,\n          onTap: () => widget.onTapItem(_items[i]),\n          child: CardExtension.highContrast(widget.highContrast)(\n            child: Padding(\n              padding: Theming.paddingAll,\n              child: Hero(\n                tag: _items[i].id,\n                child: Text(\n                  _items[i].name,\n                  style: TextTheme.of(context).bodyMedium,\n                  overflow: .ellipsis,\n                  maxLines: 1,\n                ),\n              ),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass _EditList extends StatefulWidget {\n  const _EditList(\n    this.items,\n    this.onTapItem,\n    this.onLongTapItem,\n    this.toggleFavorite,\n    this.highContrast, {\n    this.compact = false,\n  });\n\n  final List<FavoriteItem> items;\n  final void Function(FavoriteItem) onTapItem;\n  final void Function(FavoriteItem)? onLongTapItem;\n  final Future<Object?> Function(int) toggleFavorite;\n  final bool highContrast;\n  final bool compact;\n\n  @override\n  State<_EditList> createState() => _EditListState();\n}\n\nclass _EditListState extends State<_EditList> {\n  @override\n  Widget build(BuildContext context) {\n    final lineCount = widget.compact ? 1 : 4;\n    final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final itemExtent = max(lineHeight * lineCount, Theming.iconBig + 20) + 20;\n\n    return SliverReorderableList(\n      itemExtent: itemExtent,\n      itemCount: widget.items.length,\n      onReorder: (oldIndex, newIndex) => setState(() {\n        if (oldIndex < newIndex) {\n          newIndex -= 1;\n        }\n\n        final item = widget.items.removeAt(oldIndex);\n        widget.items.insert(newIndex, item);\n      }),\n      proxyDecorator: (child, index, animation) {\n        return DecoratedBox(\n          decoration: BoxDecoration(\n            boxShadow: [\n              BoxShadow(\n                color: ColorScheme.of(context).surface,\n                blurRadius: 12,\n                spreadRadius: 1,\n                // offset: Offset(0, 4 * animation.value),\n              ),\n            ],\n          ),\n          child: child,\n        );\n      },\n      itemBuilder: (context, i) {\n        final item = widget.items[i];\n\n        Widget content = Padding(\n          padding: const .only(left: 10, top: 5, bottom: 5),\n          child: Row(\n            spacing: Theming.offset,\n            children: [\n              Expanded(\n                child: Text(item.name, overflow: .ellipsis, maxLines: lineCount),\n              ),\n              IconButton(\n                icon: item.isFavorite\n                    ? const Icon(Icons.favorite)\n                    : const Icon(Icons.favorite_border_rounded),\n                tooltip: item.isFavorite ? 'Unfavorite' : 'Favorite',\n                onPressed: () async {\n                  final isFavorite = item.isFavorite;\n                  setState(() => item.isFavorite = !isFavorite);\n\n                  final err = await widget.toggleFavorite(item.id);\n                  if (err == null) return;\n\n                  setState(() => item.isFavorite = isFavorite);\n                  if (context.mounted) {\n                    SnackBarExtension.show(context, err.toString());\n                  }\n                },\n              ),\n              ReorderableDragStartListener(\n                index: i,\n                child: Padding(\n                  padding: Theming.paddingAll,\n                  child: Icon(Icons.drag_handle_rounded, size: Theming.iconBig),\n                ),\n              ),\n            ],\n          ),\n        );\n\n        if (item.imageUrl != null) {\n          content = Row(\n            children: [\n              ClipRRect(\n                borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n                child: CachedImage(item.imageUrl!, width: itemExtent / Theming.coverHtoWRatio),\n              ),\n              Expanded(child: content),\n            ],\n          );\n        }\n\n        return CardExtension.highContrast(widget.highContrast)(\n          key: Key('$i'),\n          margin: const .only(bottom: Theming.offset),\n          child: InkWell(\n            borderRadius: Theming.borderRadiusSmall,\n            onTap: () => widget.onTapItem(item),\n            onLongPress: () => widget.onLongTapItem?.call(item),\n            child: content,\n          ),\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/feed/feed_floating_action.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/feature/activity/activities_provider.dart';\nimport 'package:otraku/feature/composition/composition_model.dart';\nimport 'package:otraku/feature/composition/composition_view.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass FeedFloatingAction extends StatelessWidget {\n  const FeedFloatingAction(this.ref) : super(key: const Key('newPost'));\n\n  final WidgetRef ref;\n\n  @override\n  Widget build(BuildContext context) {\n    return FloatingActionButton(\n      tooltip: 'New Post',\n      child: const Icon(Icons.edit_outlined),\n      onPressed: () => showSheet(\n        context,\n        CompositionView(\n          tag: const StatusActivityCompositionTag(id: null),\n          onSaved: (map) =>\n              ref.read(activitiesProvider(HomeActivitiesTag.instance).notifier).prepend(map),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/feed/feed_top_bar.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/feature/activity/activity_filter_sheet.dart';\nimport 'package:otraku/feature/settings/settings_provider.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\n\nclass FeedTopBarTrailingContent extends StatelessWidget {\n  const FeedTopBarTrailingContent();\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        final count = ref.watch(settingsProvider.select((s) => s.value?.unreadNotifications ?? 0));\n\n        final openNotifications = ref.watch(viewerIdProvider) != null\n            ? () {\n                ref.read(settingsProvider.notifier).clearUnread();\n                context.push(Routes.notifications);\n              }\n            : () => SnackBarExtension.show(context, 'Log in to view notifications');\n\n        Widget notificationIcon = IconButton(\n          tooltip: 'Notifications',\n          icon: const Icon(Ionicons.notifications_outline),\n          onPressed: openNotifications,\n        );\n\n        if (count > 0) {\n          notificationIcon = Badge.count(\n            count: count,\n            maxCount: 99,\n            offset: Offset.zero,\n            alignment: Alignment.topLeft,\n            child: notificationIcon,\n          );\n        }\n\n        return Row(\n          children: [\n            IconButton(\n              tooltip: 'Forum',\n              icon: const Icon(Ionicons.chatbubbles_outline),\n              onPressed: () => context.push(Routes.forum),\n            ),\n            notificationIcon,\n            IconButton(\n              tooltip: 'Filter',\n              icon: const Icon(Ionicons.funnel_outline),\n              onPressed: () => showActivityFilterSheet(context, ref, HomeActivitiesTag.instance),\n            ),\n          ],\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/forum/forum_filter_model.dart",
    "content": "import 'package:otraku/extension/iterable_extension.dart';\n\nclass ForumFilter {\n  const ForumFilter({\n    required this.search,\n    required this.category,\n    required this.isSubscribed,\n    required this.sort,\n  });\n\n  final String search;\n  final ThreadCategory? category;\n  final bool isSubscribed;\n  final ThreadSort sort;\n\n  ForumFilter copyWith({\n    String? search,\n    (ThreadCategory?,)? category,\n    bool? isSubscribed,\n    ThreadSort? sort,\n  }) => ForumFilter(\n    search: search ?? this.search,\n    category: category == null ? this.category : category.$1,\n    isSubscribed: isSubscribed ?? this.isSubscribed,\n    sort: sort ?? this.sort,\n  );\n\n  Map<String, dynamic> toGraphQlVariables() => {\n    if (search.isNotEmpty) 'search': search,\n    if (isSubscribed) 'subscribed': true,\n    if (category != null) 'categoryId': category!.id,\n    if (search.isEmpty) 'sort': sort.value else 'sort': ThreadSort.lastCreated.value,\n  };\n}\n\nenum ThreadCategory {\n  general('General', 7),\n  anime('Anime', 1),\n  manga('Manga', 2),\n  lightNovels('Light Novels', 3),\n  visualNovels('Visual Novels', 4),\n  gaming('Gaming', 10),\n  music('Music', 9),\n  news('News', 8),\n  releases('Release Discussions', 5),\n  recommendations('Recommendations', 15),\n  forumGames('Forum Games', 16),\n  miscellaneous('Misc', 17),\n  announcements('Site Announcements', 13),\n  feedback('Site Feedback', 11),\n  bugs('Bug Reports', 12),\n  apps('AniList Apps', 18);\n\n  const ThreadCategory(this.label, this.id);\n\n  final String label;\n  final int id;\n\n  static ThreadCategory? from(String? label) =>\n      ThreadCategory.values.firstWhereOrNull((v) => v.label == label);\n}\n\nenum ThreadSort {\n  pinned('Pinned', 'IS_STICKY'),\n  firstCreated('First Created', 'CREATED_AT'),\n  lastCreated('Last Created', 'CREATED_AT_DESC'),\n  lastRepliedTo('Last Replied To', 'REPLIED_AT_DESC');\n\n  const ThreadSort(this.label, this.value);\n\n  final String label;\n  final String value;\n}\n"
  },
  {
    "path": "lib/feature/forum/forum_filter_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/forum/forum_filter_model.dart';\n\nfinal forumFilterProvider = NotifierProvider.autoDispose<ForumFilterNotifier, ForumFilter>(\n  ForumFilterNotifier.new,\n);\n\nclass ForumFilterNotifier extends Notifier<ForumFilter> {\n  @override\n  ForumFilter build() =>\n      const ForumFilter(search: '', category: null, isSubscribed: false, sort: .lastRepliedTo);\n\n  void update(ForumFilter Function(ForumFilter) callback) => state = callback(state);\n}\n"
  },
  {
    "path": "lib/feature/forum/forum_filter_view.dart",
    "content": "import 'package:flutter/widgets.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/forum/forum_filter_model.dart';\nimport 'package:otraku/feature/forum/forum_filter_provider.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/widget/input/stateful_tiles.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nvoid showForumFilterSheet(BuildContext context, WidgetRef ref) async {\n  final highContrast = ref.read(persistenceProvider.select((s) => s.options.highContrast));\n  var filter = ref.read(forumFilterProvider);\n\n  await showSheet(\n    context,\n    SimpleSheet(\n      initialHeight: Theming.normalTapTarget * 4,\n      builder: (context, scrollCtrl) => ListView(\n        controller: scrollCtrl,\n        padding: const .only(top: Theming.offset),\n        children: [\n          Padding(\n            padding: const .symmetric(horizontal: Theming.offset),\n            child: ChipSelector.ensureSelected(\n              title: 'Sort',\n              items: ThreadSort.values.map((v) => (v.label, v)).toList(),\n              value: filter.sort,\n              onChanged: (v) => filter = filter.copyWith(sort: v),\n              highContrast: highContrast,\n            ),\n          ),\n          Padding(\n            padding: const .symmetric(horizontal: Theming.offset),\n            child: ChipSelector(\n              title: 'Category',\n              items: ThreadCategory.values.map((v) => (v.label, v)).toList(),\n              value: filter.category,\n              onChanged: (v) => filter = filter.copyWith(category: (v,)),\n              highContrast: highContrast,\n            ),\n          ),\n          StatefulSwitchListTile(\n            title: const Text('Subscribed'),\n            value: filter.isSubscribed,\n            onChanged: (v) => filter = filter.copyWith(isSubscribed: v),\n          ),\n        ],\n      ),\n    ),\n  );\n\n  ref.read(forumFilterProvider.notifier).update((_) => filter);\n}\n"
  },
  {
    "path": "lib/feature/forum/forum_model.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\n\nclass ThreadItem {\n  const ThreadItem._({\n    required this.id,\n    required this.title,\n    required this.viewCount,\n    required this.replyCount,\n    required this.likeCount,\n    required this.isSubscribed,\n    required this.isPinned,\n    required this.isLocked,\n    required this.userId,\n    required this.userName,\n    required this.userAvatar,\n    required this.userTimestamp,\n    required this.isUserReplying,\n    required this.topics,\n  });\n\n  factory ThreadItem(Map<String, dynamic> map) {\n    final topics = <String>[];\n\n    for (final c in map['categories'] ?? const []) {\n      topics.add(c['name']);\n    }\n\n    for (final c in map['mediaCategories'] ?? const []) {\n      topics.add(c['title']?['userPreferred'] ?? '?');\n    }\n\n    final (\n      int userId,\n      String userName,\n      String userAvatar,\n      DateTime userTimestamp,\n      bool isUserReplying,\n    ) = map['repliedAt'] != null\n        ? (\n            map['replyUser']?['id'] ?? 0,\n            map['replyUser']?['name'] ?? '?',\n            map['replyUser']?['avatar']?['large'] ?? '',\n            DateTimeExtension.fromSecondsSinceEpoch(map['repliedAt']),\n            true,\n          )\n        : (\n            map['user']?['id'] ?? 0,\n            map['user']?['name'] ?? '?',\n            map['user']?['avatar']?['large'] ?? '',\n            DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),\n            false,\n          );\n\n    return ThreadItem._(\n      id: map['id'],\n      title: map['title'] ?? '?',\n      viewCount: map['viewCount'] ?? 0,\n      replyCount: map['replyCount'] ?? 0,\n      likeCount: map['likeCount'] ?? 0,\n      isSubscribed: map['isSubscribed'] ?? false,\n      isPinned: map['isSticky'] ?? false,\n      isLocked: map['isLocked'] ?? false,\n      userId: userId,\n      userName: userName,\n      userAvatar: userAvatar,\n      userTimestamp: userTimestamp,\n      isUserReplying: isUserReplying,\n      topics: topics,\n    );\n  }\n\n  final int id;\n  final String title;\n  final int viewCount;\n  final int replyCount;\n  final int likeCount;\n  final bool isSubscribed;\n  final bool isPinned;\n  final bool isLocked;\n  final int userId;\n  final String userName;\n  final String userAvatar;\n  final DateTime userTimestamp;\n  final bool isUserReplying;\n  final List<String> topics;\n}\n"
  },
  {
    "path": "lib/feature/forum/forum_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/forum/forum_filter_model.dart';\nimport 'package:otraku/feature/forum/forum_filter_provider.dart';\nimport 'package:otraku/feature/forum/forum_model.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\nimport 'package:otraku/util/paged.dart';\n\nfinal forumProvider = AsyncNotifierProvider.autoDispose<ForumNotifier, Paged<ThreadItem>>(\n  ForumNotifier.new,\n);\n\nclass ForumNotifier extends AsyncNotifier<Paged<ThreadItem>> {\n  late ForumFilter _filter;\n\n  @override\n  FutureOr<Paged<ThreadItem>> build() {\n    _filter = ref.watch(forumFilterProvider);\n    return _fetch(const Paged());\n  }\n\n  Future<void> fetch() async {\n    final oldState = state.value ?? const Paged();\n    if (!oldState.hasNext) return;\n    state = await AsyncValue.guard(() => _fetch(oldState));\n  }\n\n  Future<Paged<ThreadItem>> _fetch(Paged<ThreadItem> oldState) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.threadPage, {\n      'page': oldState.next,\n      ..._filter.toGraphQlVariables(),\n    });\n\n    final items = <ThreadItem>[];\n    for (final t in data['Page']['threads']) {\n      items.add(ThreadItem(t));\n    }\n\n    return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false);\n  }\n}\n"
  },
  {
    "path": "lib/feature/forum/forum_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/forum/forum_filter_provider.dart';\nimport 'package:otraku/feature/forum/forum_filter_view.dart';\nimport 'package:otraku/feature/forum/forum_provider.dart';\nimport 'package:otraku/feature/forum/thread_item_list.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/debounce.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/widget/input/search_field.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/paged_view.dart';\n\nclass ForumView extends ConsumerStatefulWidget {\n  const ForumView();\n\n  @override\n  ConsumerState<ForumView> createState() => _ForumViewState();\n}\n\nclass _ForumViewState extends ConsumerState<ForumView> {\n  late final _scrollCtrl = PagedController(\n    loadMore: () => ref.read(forumProvider.notifier).fetch(),\n  );\n\n  @override\n  void dispose() {\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    return AdaptiveScaffold(\n      topBar: TopBar(\n        trailing: [\n          Consumer(\n            builder: (context, ref, filterButton) {\n              return Expanded(\n                child: Row(\n                  children: [\n                    Expanded(\n                      child: SearchField(\n                        debounce: Debounce(),\n                        hint: 'Forum',\n                        value: ref.watch(forumFilterProvider.select((s) => s.search)),\n                        onChanged: (search) => ref\n                            .read(forumFilterProvider.notifier)\n                            .update((s) => s.copyWith(search: search.trim())),\n                      ),\n                    ),\n                    filterButton!,\n                  ],\n                ),\n              );\n            },\n            child: IconButton(\n              tooltip: 'Filter',\n              icon: const Icon(Ionicons.funnel_outline),\n              onPressed: () => showForumFilterSheet(context, ref),\n            ),\n          ),\n        ],\n      ),\n      child: PagedView(\n        provider: forumProvider,\n        scrollCtrl: _scrollCtrl,\n        onRefresh: (invalidate) => invalidate(forumProvider),\n        onData: (data) => ThreadItemList(data.items, options.highContrast, options.analogClock),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/forum/thread_item_list.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/forum/forum_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/text_rail.dart';\nimport 'package:otraku/widget/timestamp.dart';\n\nclass ThreadItemList extends StatelessWidget {\n  const ThreadItemList(this.items, this.highContrast, this.analogClock);\n\n  final List<ThreadItem> items;\n  final bool highContrast;\n  final bool analogClock;\n\n  @override\n  Widget build(BuildContext context) {\n    return SliverList.builder(\n      itemCount: items.length,\n      itemBuilder: (context, i) {\n        final item = items[i];\n\n        return Padding(\n          padding: const .only(bottom: Theming.offset),\n          child: Column(\n            mainAxisSize: .min,\n            crossAxisAlignment: .start,\n            spacing: Theming.offset,\n            children: [\n              Row(\n                spacing: Theming.offset,\n                children: [\n                  GestureDetector(\n                    onTap: () => context.push(Routes.user(item.userId, item.userAvatar)),\n                    child: ClipRRect(\n                      borderRadius: Theming.borderRadiusSmall,\n                      child: CachedImage(item.userAvatar, height: 50, width: 50),\n                    ),\n                  ),\n                  Expanded(\n                    child: OverflowBar(\n                      spacing: 5,\n                      overflowSpacing: 5,\n                      children: [\n                        Text(item.userName, overflow: .ellipsis, maxLines: 1),\n                        Timestamp(\n                          item.userTimestamp,\n                          analogClock,\n                          leading: Text(\n                            item.isUserReplying ? 'replied' : 'posted',\n                            style: TextTheme.of(context).labelSmall,\n                          ),\n                        ),\n                      ],\n                    ),\n                  ),\n                ],\n              ),\n              CardExtension.highContrast(highContrast)(\n                child: InkWell(\n                  borderRadius: Theming.borderRadiusSmall,\n                  onTap: () => context.push(Routes.thread(item.id)),\n                  child: Padding(\n                    padding: Theming.paddingAll,\n                    child: Column(\n                      spacing: Theming.offset,\n                      mainAxisSize: .min,\n                      crossAxisAlignment: .start,\n                      children: [\n                        Text(item.title),\n                        TextRail({for (final topic in item.topics) topic: false}),\n                        Row(\n                          spacing: Theming.offset,\n                          children: [\n                            if (item.isPinned)\n                              Tooltip(\n                                message: 'Pinned',\n                                triggerMode: .tap,\n                                child: Icon(Icons.push_pin_outlined, size: Theming.iconSmall),\n                              ),\n                            if (item.isLocked)\n                              Tooltip(\n                                message: 'Locked',\n                                triggerMode: .tap,\n                                child: Icon(Icons.lock_outline_rounded, size: Theming.iconSmall),\n                              ),\n                            const Spacer(),\n                            _buildInfoIcon(\n                              context,\n                              'Views',\n                              item.viewCount.toString(),\n                              Icons.remove_red_eye_outlined,\n                            ),\n                            _buildInfoIcon(\n                              context,\n                              'Replies',\n                              item.replyCount.toString(),\n                              Icons.reply_rounded,\n                            ),\n                            _buildInfoIcon(\n                              context,\n                              'Likes',\n                              item.likeCount.toString(),\n                              Icons.favorite_outline_rounded,\n                            ),\n                          ],\n                        ),\n                      ],\n                    ),\n                  ),\n                ),\n              ),\n            ],\n          ),\n        );\n      },\n    );\n  }\n\n  Widget _buildInfoIcon(BuildContext context, String label, String value, IconData icon) => Tooltip(\n    message: label,\n    triggerMode: .tap,\n    child: Row(\n      mainAxisSize: .min,\n      spacing: 5,\n      children: [\n        Text(value, style: Theme.of(context).textTheme.labelSmall),\n        Icon(icon, size: Theming.iconSmall),\n      ],\n    ),\n  );\n}\n"
  },
  {
    "path": "lib/feature/home/home_model.dart",
    "content": "class Home {\n  const Home({required this.didExpandAnimeCollection, required this.didExpandMangaCollection});\n\n  /// In preview mode, user's collections first load only current media.\n  /// The rest is loaded by a manual request from the user\n  /// and thus the collection \"expands\".\n  /// If preview mode is off, collections are auto-expanded\n  /// and immediately load everything.\n  final bool didExpandAnimeCollection;\n  final bool didExpandMangaCollection;\n\n  Home withExpandedCollection(bool ofAnime) => ofAnime\n      ? Home(didExpandAnimeCollection: true, didExpandMangaCollection: didExpandMangaCollection)\n      : Home(didExpandAnimeCollection: didExpandAnimeCollection, didExpandMangaCollection: true);\n}\n\nenum HomeTab {\n  feed('Feed'),\n  anime('Anime'),\n  manga('Manga'),\n  discover('Discover'),\n  profile('Profile');\n\n  const HomeTab(this.label);\n\n  final String label;\n}\n"
  },
  {
    "path": "lib/feature/home/home_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/home/home_model.dart';\n\nfinal homeProvider = NotifierProvider.autoDispose<HomeNotifier, Home>(HomeNotifier.new);\n\nclass HomeNotifier extends Notifier<Home> {\n  @override\n  Home build() {\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    return switch (stateOrNull) {\n      Home oldState => oldState,\n      null => Home(\n        didExpandAnimeCollection: !options.animeCollectionPreview,\n        didExpandMangaCollection: !options.mangaCollectionPreview,\n      ),\n    };\n  }\n\n  void expandCollection(bool ofAnime) => state = state.withExpandedCollection(ofAnime);\n}\n"
  },
  {
    "path": "lib/feature/home/home_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/scroll_controller_extension.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/feature/activity/activities_provider.dart';\nimport 'package:otraku/feature/activity/activities_view.dart';\nimport 'package:otraku/feature/collection/collection_entries_provider.dart';\nimport 'package:otraku/feature/collection/collection_floating_action.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/collection/collection_top_bar.dart';\nimport 'package:otraku/feature/discover/discover_floating_action.dart';\nimport 'package:otraku/feature/discover/discover_provider.dart';\nimport 'package:otraku/feature/discover/discover_top_bar.dart';\nimport 'package:otraku/feature/feed/feed_floating_action.dart';\nimport 'package:otraku/feature/feed/feed_top_bar.dart';\nimport 'package:otraku/feature/home/home_model.dart';\nimport 'package:otraku/feature/home/home_provider.dart';\nimport 'package:otraku/feature/settings/settings_provider.dart';\nimport 'package:otraku/feature/tag/tag_provider.dart';\nimport 'package:otraku/feature/user/user_providers.dart';\nimport 'package:otraku/feature/user/user_view.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/feature/discover/discover_view.dart';\nimport 'package:otraku/feature/collection/collection_view.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\n\nclass HomeView extends ConsumerStatefulWidget {\n  const HomeView({super.key, this.tab});\n\n  final HomeTab? tab;\n\n  @override\n  ConsumerState<HomeView> createState() => _HomeViewState();\n}\n\nclass _HomeViewState extends ConsumerState<HomeView> with SingleTickerProviderStateMixin {\n  final _animeFocusNode = FocusNode();\n  final _mangaFocusNode = FocusNode();\n  final _discoverFocusNode = FocusNode();\n\n  final _animeScrollCtrl = ScrollController();\n  final _mangaScrollCtrl = ScrollController();\n  late final _feedScrollCtrl = PagedController(\n    loadMore: () => ref.read(activitiesProvider(HomeActivitiesTag.instance).notifier).fetch(),\n  );\n  late final _discoverScrollCtrl = PagedController(\n    loadMore: () => ref.read(discoverProvider.notifier).fetch(),\n  );\n\n  late final _tabCtrl = TabController(length: HomeTab.values.length, vsync: this);\n\n  @override\n  void initState() {\n    super.initState();\n    final persistence = ref.read(persistenceProvider);\n\n    _tabCtrl.index = persistence.options.homeTab.index;\n    if (widget.tab != null) _tabCtrl.index = widget.tab!.index;\n\n    _tabCtrl.addListener(\n      () => WidgetsBinding.instance.addPostFrameCallback((_) {\n        final tab = HomeTab.values[_tabCtrl.index];\n        if (tab != .anime) _animeFocusNode.unfocus();\n        if (tab != .manga) _mangaFocusNode.unfocus();\n        if (tab != .discover) _discoverFocusNode.unfocus();\n        context.go(Routes.home(tab));\n      }),\n    );\n  }\n\n  @override\n  void didUpdateWidget(covariant HomeView oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    if (widget.tab != null) _tabCtrl.index = widget.tab!.index;\n  }\n\n  @override\n  void dispose() {\n    ref.invalidate(discoverProvider);\n    ref.invalidate(activitiesProvider(HomeActivitiesTag.instance));\n\n    _animeFocusNode.dispose();\n    _mangaFocusNode.dispose();\n    _discoverFocusNode.dispose();\n\n    _animeScrollCtrl.dispose();\n    _mangaScrollCtrl.dispose();\n    _feedScrollCtrl.dispose();\n    _discoverScrollCtrl.dispose();\n\n    _tabCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    ref.watch(settingsProvider.select((_) => null));\n    ref.watch(tagsProvider.select((_) => null));\n\n    UserTag? userTag;\n    CollectionTag? animeCollectionTag;\n    CollectionTag? mangaCollectionTag;\n\n    final viewerId = ref.watch(viewerIdProvider);\n    if (viewerId != null) {\n      userTag = idUserTag(viewerId);\n      animeCollectionTag = (userId: viewerId, ofAnime: true);\n      mangaCollectionTag = (userId: viewerId, ofAnime: false);\n\n      ref.watch(userProvider(userTag).select((_) => null));\n      ref.watch(collectionEntriesProvider(animeCollectionTag).select((_) => null));\n      ref.watch(collectionEntriesProvider(mangaCollectionTag).select((_) => null));\n    }\n\n    final home = ref.watch(homeProvider);\n    final primaryScrollCtrl = PrimaryScrollController.of(context);\n    final formFactor = Theming.of(context).formFactor;\n\n    final topBar = TopBarAnimatedSwitcher(switch (_tabCtrl.index) {\n      0 => const TopBar(\n        key: Key('feedTopBar'),\n        title: 'Feed',\n        trailing: [FeedTopBarTrailingContent()],\n      ),\n      1 when animeCollectionTag != null => TopBar(\n        key: const Key('animeCollectionTopBar'),\n        trailing: [CollectionTopBarTrailingContent(animeCollectionTag, _animeFocusNode)],\n      ),\n      2 when mangaCollectionTag != null => TopBar(\n        key: const Key('mangaCollectionTopBar'),\n        trailing: [CollectionTopBarTrailingContent(mangaCollectionTag, _mangaFocusNode)],\n      ),\n      3 => TopBar(\n        key: const Key('discoverTobBar'),\n        trailing: [DiscoverTopBarTrailingContent(_discoverFocusNode)],\n      ),\n      _ => const EmptyTopBar() as PreferredSizeWidget,\n    });\n\n    final navigationConfig = NavigationConfig(\n      items: _homeTabs,\n      selected: _tabCtrl.index,\n      onChanged: (i) => context.go(Routes.home(HomeTab.values[i])),\n      onSame: (i) {\n        final tab = HomeTab.values[i];\n\n        switch (tab) {\n          case .feed:\n            _feedScrollCtrl.scrollToTop();\n          case .anime:\n            if (_animeScrollCtrl.position.pixels > 0) {\n              _animeScrollCtrl.scrollToTop();\n              return;\n            }\n\n            _toggleSearchFocus(_animeFocusNode);\n          case .manga:\n            if (_mangaScrollCtrl.position.pixels > 0) {\n              _mangaScrollCtrl.scrollToTop();\n              return;\n            }\n\n            _toggleSearchFocus(_mangaFocusNode);\n          case .discover:\n            if (_discoverScrollCtrl.position.pixels > 0) {\n              _discoverScrollCtrl.scrollToTop();\n              return;\n            }\n\n            _toggleSearchFocus(_discoverFocusNode);\n            return;\n          case .profile:\n            if (primaryScrollCtrl.positions.last.pixels > 0) {\n              primaryScrollCtrl.scrollToTop();\n              return;\n            }\n\n            context.push(Routes.settings);\n        }\n      },\n    );\n\n    final floatingAction = switch (_tabCtrl.index) {\n      0 => HidingFloatingActionButton(\n        key: const Key('feed'),\n        scrollCtrl: _feedScrollCtrl,\n        child: FeedFloatingAction(ref),\n      ),\n      1 =>\n        (formFactor == .phone || !home.didExpandAnimeCollection) && animeCollectionTag != null\n            ? HidingFloatingActionButton(\n                key: const Key('anime'),\n                scrollCtrl: _animeScrollCtrl,\n                child: CollectionFloatingAction(animeCollectionTag),\n              )\n            : null,\n      2 =>\n        (formFactor == .phone || !home.didExpandMangaCollection) && mangaCollectionTag != null\n            ? HidingFloatingActionButton(\n                key: const Key('manga'),\n                scrollCtrl: _mangaScrollCtrl,\n                child: CollectionFloatingAction(mangaCollectionTag),\n              )\n            : null,\n      3 =>\n        formFactor == .phone\n            ? HidingFloatingActionButton(\n                key: const Key('discover'),\n                scrollCtrl: _discoverScrollCtrl,\n                child: const DiscoverFloatingAction(),\n              )\n            : null,\n      _ => null,\n    };\n\n    final child = TabBarView(\n      controller: _tabCtrl,\n      children: [\n        ActivitiesSubView(HomeActivitiesTag.instance, _feedScrollCtrl),\n        CollectionSubview(\n          scrollCtrl: _animeScrollCtrl,\n          tag: animeCollectionTag,\n          formFactor: formFactor,\n          key: Key(true.toString()),\n        ),\n        CollectionSubview(\n          scrollCtrl: _mangaScrollCtrl,\n          tag: mangaCollectionTag,\n          formFactor: formFactor,\n          key: Key(false.toString()),\n        ),\n        DiscoverSubview(_discoverScrollCtrl, formFactor),\n        UserHomeView(\n          userTag,\n          null,\n          homeScrollCtrl: primaryScrollCtrl,\n          removableTopPadding: topBar.preferredSize.height,\n        ),\n      ],\n    );\n\n    return AdaptiveScaffold(\n      topBar: topBar,\n      floatingAction: floatingAction,\n      navigationConfig: navigationConfig,\n      child: child,\n    );\n  }\n\n  static final _homeTabs = {\n    HomeTab.feed.label: Ionicons.file_tray_outline,\n    HomeTab.anime.label: Ionicons.film_outline,\n    HomeTab.manga.label: Ionicons.book_outline,\n    HomeTab.discover.label: Ionicons.compass_outline,\n    HomeTab.profile.label: Ionicons.person_outline,\n  };\n\n  void _toggleSearchFocus(FocusNode node) => node.hasFocus ? node.unfocus() : node.requestFocus();\n}\n"
  },
  {
    "path": "lib/feature/media/media_activities_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/activity/activities_filter_model.dart';\nimport 'package:otraku/feature/activity/activities_filter_provider.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/feature/activity/activities_provider.dart';\nimport 'package:otraku/feature/activity/activity_card.dart';\nimport 'package:otraku/feature/activity/activity_model.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/paged_view.dart';\n\nclass MediaActivitiesSubview extends StatelessWidget {\n  const MediaActivitiesSubview({\n    required this.ref,\n    required this.tag,\n    required this.scrollCtrl,\n    required this.viewerId,\n    required this.options,\n  });\n\n  final WidgetRef ref;\n  final MediaActivitiesTag tag;\n  final ScrollController scrollCtrl;\n  final int? viewerId;\n  final Options options;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(activitiesProvider(tag)),\n      provider: activitiesProvider(tag),\n      header: _FollowingFilterButton(ref, tag),\n      onData: (data) => SliverList(\n        delegate: SliverChildBuilderDelegate(\n          childCount: data.items.length,\n          (context, i) => ActivityCard(\n            withHeader: true,\n            analogClock: options.analogClock,\n            highContrast: options.highContrast,\n            activity: data.items[i],\n            footer: ActivityFooter(\n              viewerId: viewerId,\n              activity: data.items[i],\n              toggleLike: () =>\n                  ref.read(activitiesProvider(tag).notifier).toggleLike(data.items[i]),\n              toggleSubscription: () =>\n                  ref.read(activitiesProvider(tag).notifier).toggleSubscription(data.items[i]),\n              togglePin: () => ref.read(activitiesProvider(tag).notifier).togglePin(data.items[i]),\n              remove: () => ref.read(activitiesProvider(tag).notifier).remove(data.items[i]),\n              onEdited: (map) {\n                final activity = Activity.maybe(map, viewerId, options.imageQuality);\n\n                if (activity == null) return;\n\n                ref.read(activitiesProvider(tag).notifier).replace(activity);\n              },\n              reply: () => context.push(Routes.activity(data.items[i].id, null)),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass _FollowingFilterButton extends StatelessWidget {\n  const _FollowingFilterButton(this.ref, this.tag);\n\n  final WidgetRef ref;\n  final MediaActivitiesTag tag;\n\n  @override\n  Widget build(BuildContext context) {\n    final filter = ref.watch(activitiesFilterProvider(tag));\n\n    return SliverToBoxAdapter(\n      child: SizedBox(\n        height: Theming.normalTapTarget,\n        child: switch (filter) {\n          MediaActivitiesFilter filter => Row(\n            spacing: Theming.offset,\n            children: [\n              FilterChip(\n                label: const Text(\"Global\"),\n                selected: filter.socialGroup == .global,\n                onSelected: (val) => ref.read(activitiesFilterProvider(tag).notifier).state = filter\n                    .copyWith(socialGroup: .global),\n              ),\n              FilterChip(\n                label: const Text(\"Following\"),\n                selected: filter.socialGroup == .followed,\n                onSelected: (val) => ref.read(activitiesFilterProvider(tag).notifier).state = filter\n                    .copyWith(socialGroup: .followed),\n              ),\n              FilterChip(\n                label: const Text(\"Self\"),\n                selected: filter.socialGroup == .self,\n                onSelected: (val) => ref.read(activitiesFilterProvider(tag).notifier).state = filter\n                    .copyWith(socialGroup: .self),\n              ),\n            ],\n          ),\n          _ => const SizedBox.shrink(),\n        },\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_characters_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/grid/dual_relation_grid.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/feature/media/media_provider.dart';\nimport 'package:otraku/widget/shadowed_overflow_list.dart';\n\nclass MediaCharactersSubview extends StatelessWidget {\n  const MediaCharactersSubview({\n    required this.id,\n    required this.scrollCtrl,\n    required this.highContrast,\n  });\n\n  final int id;\n  final ScrollController scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView<(MediaRelatedItem, MediaRelatedItem?)>(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(mediaConnectionsProvider(id)),\n      provider: mediaConnectionsProvider(\n        id,\n      ).select((s) => s.unwrapPrevious().whenData((data) => data.getCharactersAndVoiceActors())),\n      onData: (data) {\n        return SliverMainAxisGroup(\n          slivers: [\n            _LanguageSelector(id),\n            DualRelationGrid(\n              items: data.items,\n              onTapPrimary: (item) =>\n                  context.push(Routes.character(item.tileId, item.tileImageUrl)),\n              onTapSecondary: (item) => context.push(Routes.staff(item.tileId, item.tileImageUrl)),\n              highContrast: highContrast,\n            ),\n          ],\n        );\n      },\n    );\n  }\n}\n\nclass _LanguageSelector extends StatelessWidget {\n  const _LanguageSelector(this.id);\n\n  final int id;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, child) {\n        final selection = ref.watch(\n          mediaConnectionsProvider(id).select((s) {\n            final value = s.value;\n            if (value == null) return null;\n            return (value.languageToVoiceActors, value.selectedLanguage);\n          }),\n        );\n\n        if (selection == null) return const SliverToBoxAdapter();\n\n        final languageMappings = selection.$1;\n        final selectedLanguage = selection.$2;\n\n        if (languageMappings.length < 2) return const SliverToBoxAdapter();\n\n        return SliverToBoxAdapter(\n          child: SizedBox(\n            height: Theming.normalTapTarget,\n            child: ShadowedOverflowList(\n              itemCount: languageMappings.length,\n              itemBuilder: (context, i) => FilterChip(\n                label: Text(languageMappings[i].language),\n                selected: i == selectedLanguage,\n                onSelected: (selected) {\n                  if (!selected) return;\n\n                  ref.read(mediaConnectionsProvider(id).notifier).changeLanguage(i);\n                },\n              ),\n            ),\n          ),\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_floating_actions.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/feature/edit/edit_view.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass MediaEditButton extends StatefulWidget {\n  const MediaEditButton(this.media);\n\n  final Media media;\n\n  @override\n  State<MediaEditButton> createState() => _MediaEditButtonState();\n}\n\nclass _MediaEditButtonState extends State<MediaEditButton> {\n  @override\n  Widget build(BuildContext context) {\n    final media = widget.media;\n    return FloatingActionButton(\n      tooltip: media.entryEdit.listStatus == null ? 'Add' : 'Edit',\n      child: media.entryEdit.listStatus == null\n          ? const Icon(Icons.add)\n          : const Icon(Icons.edit_outlined),\n      onPressed: () => showSheet(\n        context,\n        EditView((\n          id: media.info.id,\n          setComplete: false,\n        ), callback: (entryEdit) => setState(() => media.entryEdit = entryEdit)),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_following_view.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/input/note_label.dart';\nimport 'package:otraku/widget/input/score_label.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/media/media_provider.dart';\n\nclass MediaFollowingSubview extends StatelessWidget {\n  const MediaFollowingSubview({\n    required this.id,\n    required this.scrollCtrl,\n    required this.highContrast,\n  });\n\n  final int id;\n  final ScrollController scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(mediaFollowingProvider(id)),\n      provider: mediaFollowingProvider(id),\n      onData: (data) => _MediaFollowingGrid(data.items, highContrast),\n    );\n  }\n}\n\nclass _MediaFollowingGrid extends StatelessWidget {\n  const _MediaFollowingGrid(this.items, this.highContrast);\n\n  final List<MediaFollowing> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final tileHeight = bodyMediumLineHeight + max(bodyMediumLineHeight, 35) + 5;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 300, height: tileHeight),\n      delegate: SliverChildBuilderDelegate(\n        childCount: items.length,\n        (context, i) => GestureDetector(\n          behavior: .opaque,\n          onTap: () => context.push(Routes.user(items[i].userId, items[i].userAvatar)),\n          child: CardExtension.highContrast(highContrast)(\n            child: Row(\n              children: [\n                Hero(\n                  tag: items[i].userId,\n                  child: ClipRRect(\n                    borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n                    child: CachedImage(items[i].userAvatar, width: tileHeight),\n                  ),\n                ),\n                Expanded(\n                  child: Padding(\n                    padding: const .only(top: 5, left: Theming.offset, right: Theming.offset),\n                    child: Column(\n                      mainAxisAlignment: .spaceBetween,\n                      crossAxisAlignment: .start,\n                      children: [\n                        Text(items[i].userName, overflow: .ellipsis, maxLines: 1),\n                        SizedBox(\n                          height: 35,\n                          child: Row(\n                            mainAxisAlignment: .spaceBetween,\n                            children: [\n                              Flexible(\n                                child: Text(\n                                  items[i].entryStatus.label(null),\n                                  overflow: .ellipsis,\n                                  maxLines: 1,\n                                ),\n                              ),\n                              NotesLabel(items[i].notes),\n                              ScoreLabel(items[i].score, items[i].scoreFormat),\n                            ],\n                          ),\n                        ),\n                      ],\n                    ),\n                  ),\n                ),\n              ],\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_header.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/content_header.dart';\nimport 'package:otraku/widget/text_rail.dart';\n\nclass MediaHeader extends StatelessWidget {\n  const MediaHeader.withTabBar({\n    required this.id,\n    required this.coverUrl,\n    required this.media,\n    required TabController this.tabCtrl,\n    required void Function() this.scrollToTop,\n    required this.toggleFavorite,\n  });\n\n  const MediaHeader.withoutTabBar({\n    required this.id,\n    required this.coverUrl,\n    required this.media,\n    required this.toggleFavorite,\n  }) : tabCtrl = null,\n       scrollToTop = null;\n\n  final int id;\n  final String? coverUrl;\n  final Media? media;\n  final TabController? tabCtrl;\n  final void Function()? scrollToTop;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  Widget build(BuildContext context) {\n    final textRailItems = <String, bool>{};\n\n    if (media != null) {\n      final info = media!.info;\n\n      if (info.isAdult) textRailItems['Adult'] = true;\n\n      if (info.format != null) {\n        textRailItems[info.format!.label] = false;\n      }\n\n      if (media!.entryEdit.listStatus != null) {\n        textRailItems[media!.entryEdit.listStatus!.label(info.isAnime)] = false;\n      }\n\n      if (info.airingAt != null) {\n        textRailItems['Ep ${info.nextEpisode} in '\n                '${info.airingAt!.timeUntil}'] =\n            true;\n      }\n\n      if (media!.entryEdit.listStatus != null) {\n        final progress = media!.entryEdit.progress;\n        if (info.nextEpisode != null && info.nextEpisode! - 1 > progress) {\n          textRailItems['${info.nextEpisode! - 1 - progress}'\n                  ' ep behind'] =\n              true;\n        }\n      }\n    }\n\n    return ContentHeader(\n      bannerUrl: media?.info.banner,\n      imageUrl: media?.info.cover ?? coverUrl,\n      imageLargeUrl: media?.info.extraLargeCover,\n      imageHeightToWidthRatio: Theming.coverHtoWRatio,\n      imageHeroTag: id,\n      siteUrl: media?.info.siteUrl,\n      title: media?.info.preferredTitle,\n      details: [TextRail(textRailItems, style: TextTheme.of(context).labelMedium)],\n      tabBarConfig: tabCtrl != null && scrollToTop != null\n          ? (tabCtrl: tabCtrl!, scrollToTop: scrollToTop!, tabs: tabsWithOverview)\n          : null,\n      trailingTopButtons: [if (media != null) _FavoriteButton(media!.info, toggleFavorite)],\n    );\n  }\n\n  static const tabsWithoutOverview = [\n    Tab(text: 'Related'),\n    Tab(text: 'Characters'),\n    Tab(text: 'Staff'),\n    Tab(text: 'Reviews'),\n    Tab(text: 'Threads'),\n    Tab(text: 'Following'),\n    Tab(text: 'Activities'),\n    Tab(text: 'Recommendations'),\n    Tab(text: 'Statistics'),\n  ];\n\n  static const tabsWithOverview = [Tab(text: 'Overview'), ...tabsWithoutOverview];\n}\n\nclass _FavoriteButton extends StatefulWidget {\n  const _FavoriteButton(this.info, this.toggleFavorite);\n\n  final MediaInfo info;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  State<_FavoriteButton> createState() => __FavoriteButtonState();\n}\n\nclass __FavoriteButtonState extends State<_FavoriteButton> {\n  @override\n  Widget build(BuildContext context) {\n    final info = widget.info;\n\n    return IconButton(\n      tooltip: info.isFavorite ? 'Unfavourite' : 'Favourite',\n      icon: info.isFavorite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border),\n      onPressed: () async {\n        setState(() => info.isFavorite = !info.isFavorite);\n\n        final err = await widget.toggleFavorite();\n        if (err == null) return;\n\n        setState(() => info.isFavorite = !info.isFavorite);\n        if (context.mounted) SnackBarExtension.show(context, err.toString());\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_item_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/feature/media/media_item_model.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\n\nclass MediaItemGrid extends StatelessWidget {\n  const MediaItemGrid(this.items);\n\n  final List<MediaItem> items;\n\n  @override\n  Widget build(BuildContext context) {\n    return SliverGrid(\n      gridDelegate: const SliverGridDelegateWithMinWidthAndExtraHeight(\n        minWidth: 100,\n        extraHeight: 40,\n        rawHWRatio: Theming.coverHtoWRatio,\n      ),\n      delegate: SliverChildBuilderDelegate((_, i) => _Tile(items[i]), childCount: items.length),\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile(this.item);\n\n  final MediaItem item;\n\n  @override\n  Widget build(BuildContext context) {\n    return MediaRouteTile(\n      id: item.id,\n      imageUrl: item.imageUrl,\n      child: Column(\n        children: [\n          Expanded(\n            child: Hero(\n              tag: item.id,\n              child: ClipRRect(\n                borderRadius: Theming.borderRadiusSmall,\n                child: CachedImage(item.imageUrl),\n              ),\n            ),\n          ),\n          const SizedBox(height: 5),\n          SizedBox(\n            height: 35,\n            child: Text(\n              item.name,\n              maxLines: 2,\n              overflow: .fade,\n              style: TextTheme.of(context).bodyMedium,\n            ),\n          ),\n        ],\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_item_model.dart",
    "content": "import 'package:otraku/feature/viewer/persistence_model.dart';\n\nclass MediaItem {\n  const MediaItem._({required this.id, required this.name, required this.imageUrl});\n\n  factory MediaItem(Map<String, dynamic> map, ImageQuality imageQuality) => MediaItem._(\n    id: map['id'],\n    name: map['title']['userPreferred'],\n    imageUrl: map['coverImage'][imageQuality.value],\n  );\n\n  final int id;\n  final String name;\n  final String imageUrl;\n}\n"
  },
  {
    "path": "lib/feature/media/media_models.dart",
    "content": "import 'package:flutter/widgets.dart';\nimport 'package:otraku/extension/color_extension.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/extension/iterable_extension.dart';\nimport 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/feature/edit/edit_model.dart';\nimport 'package:otraku/feature/tag/tag_model.dart';\nimport 'package:otraku/util/tile_modelable.dart';\n\nclass Media {\n  Media(this.entryEdit, this.info, this.stats, this.related);\n\n  EntryEdit entryEdit;\n  final MediaInfo info;\n  final MediaStats stats;\n  final List<RelatedMedia> related;\n}\n\nclass MediaConnections {\n  const MediaConnections({\n    this.characters = const Paged(),\n    this.staff = const Paged(),\n    this.reviews = const Paged(),\n    this.recommendations = const Paged(),\n    this.languageToVoiceActors = const [],\n    this.selectedLanguage = 0,\n  });\n\n  final Paged<MediaRelatedItem> characters;\n  final Paged<MediaRelatedItem> staff;\n  final Paged<RelatedReview> reviews;\n  final Paged<Recommendation> recommendations;\n\n  /// For each language, a list of voice actors\n  /// is mapped to the corresponding media's id.\n  final List<MediaLanguageMapping> languageToVoiceActors;\n  final int selectedLanguage;\n\n  /// Returns the characters, along with their voice actors,\n  /// corresponding to the current [language]. If there are\n  /// multiple actors, the given character is repeated for each actor.\n  Paged<(MediaRelatedItem, MediaRelatedItem?)> getCharactersAndVoiceActors() {\n    if (languageToVoiceActors.isEmpty) {\n      return Paged(\n        items: characters.items.map((c) => (c, null)).toList(),\n        hasNext: characters.hasNext,\n        next: characters.next,\n      );\n    }\n\n    final actorsPerMedia = languageToVoiceActors[selectedLanguage].voiceActors;\n\n    final charactersAndVoiceActors = <(MediaRelatedItem, MediaRelatedItem?)>[];\n    for (final c in characters.items) {\n      final actors = actorsPerMedia[c.id];\n      if (actors == null || actors.isEmpty) {\n        charactersAndVoiceActors.add((c, null));\n        continue;\n      }\n\n      for (final va in actors) {\n        charactersAndVoiceActors.add((c, va));\n      }\n    }\n\n    return Paged(\n      items: charactersAndVoiceActors,\n      hasNext: characters.hasNext,\n      next: characters.next,\n    );\n  }\n\n  MediaConnections copyWith({\n    Paged<MediaRelatedItem>? characters,\n    Paged<MediaRelatedItem>? staff,\n    Paged<RelatedReview>? reviews,\n    Paged<Recommendation>? recommendations,\n    List<MediaLanguageMapping>? languageToVoiceActors,\n    int? selectedLanguage,\n  }) => MediaConnections(\n    characters: characters ?? this.characters,\n    staff: staff ?? this.staff,\n    reviews: reviews ?? this.reviews,\n    recommendations: recommendations ?? this.recommendations,\n    languageToVoiceActors: languageToVoiceActors ?? this.languageToVoiceActors,\n    selectedLanguage: selectedLanguage ?? this.selectedLanguage,\n  );\n}\n\ntypedef MediaLanguageMapping = ({String language, Map<int, List<MediaRelatedItem>> voiceActors});\n\nclass RelatedMedia {\n  const RelatedMedia._({\n    required this.id,\n    required this.isAnime,\n    required this.title,\n    required this.imageUrl,\n    required this.relationType,\n    required this.format,\n    required this.entryStatus,\n    required this.releaseStatus,\n  });\n\n  factory RelatedMedia(Map<String, dynamic> map, ImageQuality imageQuality) => RelatedMedia._(\n    id: map['node']['id'],\n    title: map['node']['title']['userPreferred'],\n    imageUrl: map['node']['coverImage'][imageQuality.value],\n    relationType: StringExtension.tryNoScreamingSnakeCase(map['relationType']),\n    format: MediaFormat.from(map['node']['format']),\n    entryStatus: ListStatus.from(map['node']['mediaListEntry']?['status']),\n    releaseStatus: StringExtension.tryNoScreamingSnakeCase(map['node']['status']),\n    isAnime: map['node']['type'] == 'ANIME',\n  );\n\n  final int id;\n  final bool isAnime;\n  final String title;\n  final String imageUrl;\n  final String? relationType;\n  final MediaFormat? format;\n  final ListStatus? entryStatus;\n  final String? releaseStatus;\n}\n\nclass MediaRelatedItem implements TileModelable {\n  const MediaRelatedItem._({\n    required this.id,\n    required this.name,\n    required this.imageUrl,\n    required this.role,\n  });\n\n  factory MediaRelatedItem(Map<String, dynamic> map, String? role) => MediaRelatedItem._(\n    id: map['id'],\n    name: map['name']['userPreferred'],\n    imageUrl: map['image']['large'],\n    role: role,\n  );\n\n  final int id;\n  final String name;\n  final String imageUrl;\n  final String? role;\n\n  @override\n  int get tileId => id;\n\n  @override\n  String get tileTitle => name;\n\n  @override\n  String? get tileSubtitle => role;\n\n  @override\n  String get tileImageUrl => imageUrl;\n}\n\nclass RelatedReview {\n  const RelatedReview._({\n    required this.reviewId,\n    required this.userId,\n    required this.avatar,\n    required this.username,\n    required this.summary,\n    required this.rating,\n    required this.score,\n  });\n\n  static RelatedReview? maybe(Map<String, dynamic> map) {\n    if (map['user'] == null) return null;\n\n    return RelatedReview._(\n      reviewId: map['id'],\n      userId: map['user']['id'],\n      username: map['user']['name'] ?? '',\n      summary: map['summary'] ?? '',\n      avatar: map['user']['avatar']['large'],\n      rating: '${map['rating']}/${map['ratingAmount']}',\n      score: map['score'] ?? 0,\n    );\n  }\n\n  final int reviewId;\n  final int userId;\n  final String username;\n  final String avatar;\n  final String summary;\n  final String rating;\n  final int score;\n}\n\nclass MediaFollowing {\n  MediaFollowing._({\n    required this.entryStatus,\n    required this.score,\n    required this.notes,\n    required this.userId,\n    required this.userName,\n    required this.userAvatar,\n    required this.scoreFormat,\n  });\n\n  factory MediaFollowing(Map<String, dynamic> map) => MediaFollowing._(\n    entryStatus: ListStatus.from(map['status'])!,\n    score: (map['score'] ?? 0).toDouble(),\n    notes: map['notes'] ?? '',\n    userId: map['user']['id'],\n    userName: map['user']['name'],\n    userAvatar: map['user']['avatar']['large'],\n    scoreFormat: ScoreFormat.from(map['user']['mediaListOptions']?['scoreFormat']),\n  );\n\n  final ListStatus entryStatus;\n  final double score;\n  final String notes;\n  final int userId;\n  final String userName;\n  final String userAvatar;\n  final ScoreFormat scoreFormat;\n}\n\nclass Recommendation {\n  Recommendation._({\n    required this.id,\n    required this.rating,\n    required this.userRating,\n    required this.title,\n    required this.imageUrl,\n    required this.isAnime,\n    required this.releaseYear,\n    required this.format,\n    required this.entryStatus,\n  });\n\n  factory Recommendation(Map<String, dynamic> map, ImageQuality imageQuality) {\n    final userRating = map['userRating'] == 'RATE_UP'\n        ? true\n        : map['userRating'] == 'RATE_DOWN'\n        ? false\n        : null;\n\n    return Recommendation._(\n      id: map['mediaRecommendation']['id'],\n      rating: map['rating'] ?? 0,\n      userRating: userRating,\n      title: map['mediaRecommendation']['title']['userPreferred'],\n      imageUrl: map['mediaRecommendation']['coverImage'][imageQuality.value],\n      isAnime: map['mediaRecommendation']['type'] == 'ANIME',\n      releaseYear: map['mediaRecommendation']['startDate']?['year'],\n      format: MediaFormat.from(map['mediaRecommendation']['format']),\n      entryStatus: ListStatus.from(map['mediaRecommendation']['mediaListEntry']?['status']),\n    );\n  }\n\n  final int id;\n  int rating;\n  bool? userRating;\n  final String title;\n  final String imageUrl;\n  final bool isAnime;\n  final int? releaseYear;\n  final MediaFormat? format;\n  final ListStatus? entryStatus;\n}\n\nclass MediaInfo {\n  MediaInfo._({\n    required this.id,\n    required this.isAnime,\n    required this.preferredTitle,\n    required this.romajiTitle,\n    required this.englishTitle,\n    required this.nativeTitle,\n    required this.synonyms,\n    required this.cover,\n    required this.extraLargeCover,\n    required this.banner,\n    required this.description,\n    required this.format,\n    required this.status,\n    required this.nextEpisode,\n    required this.airingAt,\n    required this.episodes,\n    required this.duration,\n    required this.chapters,\n    required this.volumes,\n    required this.startDate,\n    required this.endDate,\n    required this.season,\n    required this.averageScore,\n    required this.meanScore,\n    required this.popularity,\n    required this.favourites,\n    required this.isFavorite,\n    required this.genres,\n    required this.source,\n    required this.hashtag,\n    required this.siteUrl,\n    required this.countryOfOrigin,\n    required this.isAdult,\n  });\n\n  final int id;\n  final bool isAnime;\n  final String? preferredTitle;\n  final String? romajiTitle;\n  final String? englishTitle;\n  final String? nativeTitle;\n  final List<String> synonyms;\n  final String description;\n  final String cover;\n  final String extraLargeCover;\n  final String? banner;\n  final MediaFormat? format;\n  final ReleaseStatus? status;\n  final int? nextEpisode;\n  final DateTime? airingAt;\n  final int? episodes;\n  final String? duration;\n  final int? chapters;\n  final int? volumes;\n  final String? startDate;\n  final String? endDate;\n  final String? season;\n  final int averageScore;\n  final int meanScore;\n  final int popularity;\n  final int favourites;\n  bool isFavorite;\n  final List<String> genres;\n  final studios = <String, int>{};\n  final producers = <String, int>{};\n  final tags = <Tag>[];\n  final MediaSource? source;\n  final String? hashtag;\n  final String? siteUrl;\n  final OriginCountry? countryOfOrigin;\n  final bool isAdult;\n  final externalLinks = <ExternalLink>[];\n\n  factory MediaInfo(Map<String, dynamic> map, ImageQuality imageQuality) {\n    String? duration;\n    if (map['duration'] != null) {\n      final time = map['duration'];\n      final hours = time ~/ 60;\n      final minutes = time % 60;\n      duration = '${hours != 0 ? '$hours hours ' : ''}${minutes != 0 ? '$minutes mins' : ''}';\n    }\n\n    String? season;\n    if (map['season'] != null) {\n      season = map['season'];\n      season = season![0] + season.substring(1).toLowerCase();\n      if (map['seasonYear'] != null) season += ' ${map[\"seasonYear\"]}';\n    }\n\n    String description = map['description'] ?? '';\n    description = description.replaceAll(_forbiddenDescriptionTags, '');\n\n    final model = MediaInfo._(\n      id: map['id'],\n      isAnime: map['type'] == 'ANIME',\n      preferredTitle: map['title']['userPreferred'],\n      romajiTitle: map['title']['romaji'],\n      englishTitle: map['title']['english'],\n      nativeTitle: map['title']['native'],\n      synonyms: List<String>.from(map['synonyms'] ?? [], growable: false),\n      description: description,\n      cover: map['coverImage'][imageQuality.value],\n      extraLargeCover: map['coverImage']['extraLarge'],\n      banner: map['bannerImage'],\n      format: MediaFormat.from(map['format']),\n      status: ReleaseStatus.from(map['status']),\n      nextEpisode: map['nextAiringEpisode']?['episode'],\n      airingAt: DateTimeExtension.tryFromSecondsSinceEpoch(map['nextAiringEpisode']?['airingAt']),\n      episodes: map['episodes'],\n      duration: duration,\n      chapters: map['chapters'],\n      volumes: map['volumes'],\n      startDate: StringExtension.fromFuzzyDate(map['startDate']),\n      endDate: StringExtension.fromFuzzyDate(map['endDate']),\n      season: season,\n      averageScore: map['averageScore'] ?? 0,\n      meanScore: map['meanScore'] ?? 0,\n      popularity: map['popularity'] ?? 0,\n      favourites: map['favourites'] ?? 0,\n      isFavorite: map['isFavourite'] ?? false,\n      genres: List<String>.from(map['genres'] ?? [], growable: false),\n      source: MediaSource.from(map['source']),\n      hashtag: map['hashtag'],\n      siteUrl: map['siteUrl'],\n      countryOfOrigin: OriginCountry.fromCode(map['countryOfOrigin']),\n      isAdult: map['isAdult'] ?? false,\n    );\n\n    if (map['studios'] != null) {\n      final List<dynamic> companies = map['studios']['edges'];\n      for (final company in companies) {\n        if (company['isMain']) {\n          model.studios[company['node']['name']] = company['node']['id'];\n        } else {\n          model.producers[company['node']['name']] = company['node']['id'];\n        }\n      }\n    }\n\n    if (map['tags'] != null) {\n      for (final tag in map['tags']) {\n        model.tags.add(Tag(tag));\n      }\n    }\n\n    if (map['externalLinks'] != null) {\n      for (final link in map['externalLinks']) {\n        model.externalLinks.add((\n          url: link['url'],\n          site: link['site'],\n          type: ExternalLinkType.fromString(link['type']),\n          color: link['color'] != null ? ColorExtension.fromHexString(link['color']) : null,\n          countryCode: StringExtension.languageToCode(link['language']),\n        ));\n      }\n      model.externalLinks.sort(\n        (a, b) =>\n            a.type == b.type ? a.site.compareTo(b.site) : a.type.index.compareTo(b.type.index),\n      );\n    }\n\n    return model;\n  }\n\n  /// Unexpected html tags in the description only make rendering harder.\n  static final _forbiddenDescriptionTags = RegExp('</?[^bi].?>');\n}\n\ntypedef ExternalLink = ({\n  String url,\n  String site,\n  ExternalLinkType type,\n  Color? color,\n  String? countryCode,\n});\n\nenum ExternalLinkType {\n  info,\n  social,\n  streaming;\n\n  static ExternalLinkType fromString(String? str) => switch (str) {\n    'SOCIAL' => .social,\n    'STREAMING' => .streaming,\n    _ => .info,\n  };\n}\n\nclass MediaRank {\n  const MediaRank({\n    required this.text,\n    required this.typeIsScore,\n    required this.season,\n    required this.year,\n  });\n\n  final String text;\n  final bool typeIsScore;\n  final MediaSeason? season;\n  final int? year;\n}\n\nclass MediaStats {\n  MediaStats._();\n\n  final ranks = <MediaRank>[];\n\n  final scoreNames = <int>[];\n  final scoreValues = <int>[];\n\n  final statusNames = <String>[];\n  final statusValues = <int>[];\n\n  factory MediaStats(Map<String, dynamic> map) {\n    final model = MediaStats._();\n\n    // The key is the text and the value signals\n    // if the rank is about rating or popularity.\n    if (map['rankings'] != null) {\n      for (final r in map['rankings']) {\n        final season = MediaSeason.from(r['season']);\n\n        final String when = (r['allTime'] ?? false)\n            ? 'Ever'\n            : season != null\n            ? '${season.label} ${r['year'] ?? ''}'\n            : (r['year'] ?? '').toString();\n        if (when.isEmpty) continue;\n\n        model.ranks.add(\n          MediaRank(\n            text: r['type'] == 'RATED'\n                ? '#${r[\"rank\"]} Highest Rated $when'\n                : '#${r[\"rank\"]} Most Popular $when',\n            typeIsScore: r['type'] == 'RATED',\n            season: season,\n            year: r['year'],\n          ),\n        );\n      }\n    }\n\n    if (map['stats'] != null) {\n      if (map['stats']['scoreDistribution'] != null) {\n        for (final s in map['stats']['scoreDistribution']) {\n          model.scoreNames.add(s['score']);\n          model.scoreValues.add(s['amount']);\n        }\n      }\n\n      if (map['stats']['statusDistribution'] != null) {\n        for (final s in map['stats']['statusDistribution']) {\n          int index = -1;\n          for (int i = 0; i < model.statusValues.length; i++) {\n            if (model.statusValues[i] < s['amount']) {\n              model.statusValues.insert(i, s['amount']);\n              index = i;\n              break;\n            }\n          }\n\n          if (index < 0) {\n            index = model.statusValues.length;\n            model.statusValues.add(s['amount']);\n          }\n\n          model.statusNames.insert(\n            index,\n            ListStatus.from(s['status'])!.label(map['type'] == 'ANIME'),\n          );\n        }\n      }\n    }\n\n    return model;\n  }\n}\n\nenum MediaTab {\n  info,\n  relations,\n  characters,\n  staff,\n  reviews,\n  threads,\n  following,\n  activities,\n  recommendations,\n  statistics,\n}\n\nenum MediaType {\n  anime('Anime', 'ANIME'),\n  manga('Manga', 'MANGA');\n\n  const MediaType(this.label, this.value);\n\n  final String label;\n  final String value;\n}\n\nenum ReleaseStatus {\n  finished('Finished', 'FINISHED'),\n  releasing('Releasing', 'RELEASING'),\n  notYetReleased('Not Yet Released', 'NOT_YET_RELEASED'),\n  hiatus('Hiatus', 'HIATUS'),\n  cancelled('Cancelled', 'CANCELLED');\n\n  const ReleaseStatus(this.label, this.value);\n\n  final String label;\n  final String value;\n\n  static ReleaseStatus? from(String? value) =>\n      ReleaseStatus.values.firstWhereOrNull((v) => v.value == value);\n}\n\nenum MediaFormat {\n  tv('TV', 'TV'),\n  tvShort('TV Short', 'TV_SHORT'),\n  movie('Movie', 'MOVIE'),\n  special('Special', 'SPECIAL'),\n  ova('OVA', 'OVA'),\n  ona('ONA', 'ONA'),\n  music('Music', 'MUSIC'),\n\n  manga('Manga', 'MANGA'),\n  novel('Novel', 'NOVEL'),\n  oneShot('One Shot', 'ONE_SHOT');\n\n  const MediaFormat(this.label, this.value);\n\n  final String label;\n  final String value;\n\n  static const animeFormats = [tv, tvShort, movie, special, ova, ona, music];\n  static const mangaFormats = [manga, novel, oneShot];\n\n  static MediaFormat? from(String? value) =>\n      MediaFormat.values.firstWhereOrNull((v) => v.value == value);\n}\n\nenum MediaSeason {\n  winter('Winter', 'WINTER'),\n  spring('Spring', 'SPRING'),\n  summer('Summer', 'SUMMER'),\n  fall('Fall', 'FALL');\n\n  const MediaSeason(this.label, this.value);\n\n  final String label;\n  final String value;\n\n  static MediaSeason? from(String? value) =>\n      MediaSeason.values.firstWhereOrNull((v) => v.value == value);\n}\n\nenum MediaSource {\n  original('Original', 'ORIGINAL'),\n  anime('Anime', 'ANIME'),\n  manga('Manga', 'MANGA'),\n  novel('Novel', 'NOVEL'),\n  webNovel('Web Novel', 'WEB_NOVEL'),\n  lightNovel('Light Novel', 'LIGHT_NOVEL'),\n  visualNovel('Visual Novel', 'VISUAL_NOVEL'),\n  videoGame('Video Game', 'VIDEO_GAME'),\n  doujinshi('Doujinshi', 'DOUJINSHI'),\n  game('Game', 'GAME'),\n  comic('Comic', 'COMIC'),\n  liveAction('Live Action', 'LIVE_ACTION'),\n  multimediaProject('Multimedia Project', 'MULTIMEDIA_PROJECT'),\n  pictureBook('Picture Book', 'PICTURE_BOOK'),\n  other('Other', 'OTHER');\n\n  const MediaSource(this.label, this.value);\n\n  final String label;\n  final String value;\n\n  static MediaSource? from(String? value) =>\n      MediaSource.values.firstWhereOrNull((v) => v.value == value);\n}\n\nenum OriginCountry {\n  japan('Japan', 'JP'),\n  china('China', 'CN'),\n  southKorea('South Korea', 'KR'),\n  taiwan('Taiwan', 'TW');\n\n  const OriginCountry(this.label, this.code);\n\n  final String label;\n  final String code;\n\n  static OriginCountry? fromCode(String? code) =>\n      OriginCountry.values.firstWhereOrNull((v) => v.code == code);\n}\n\nenum ScoreFormat {\n  point100('100 Points', 'POINT_100'),\n  point10Decimal('10 Decimal Points', 'POINT_10_DECIMAL'),\n  point10('10 Points', 'POINT_10'),\n  point5('5 Stars', 'POINT_5'),\n  point3('3 Smileys', 'POINT_3');\n\n  const ScoreFormat(this.label, this.value);\n\n  final String label;\n  final String value;\n\n  static ScoreFormat from(String? value) =>\n      ScoreFormat.values.firstWhere((v) => v.value == value, orElse: () => point10);\n}\n\nenum MediaSort {\n  trendingDesc('Trending', 'TRENDING_DESC'),\n  popularityDesc('Popularity', 'POPULARITY_DESC'),\n  scoreDesc('Score', 'SCORE_DESC'),\n  score('Worst Score', 'SCORE'),\n  favoritesDesc('Favourites', 'FAVOURITES_DESC'),\n  startDateDesc('Released Latest', 'START_DATE_DESC'),\n  startDate('Released Earliest', 'START_DATE'),\n  idDesc('Last Added', 'ID_DESC'),\n  id('First Added', 'ID'),\n  titleRomaji('Title Romaji', 'TITLE_ROMAJI'),\n  titleEnglish('Title English', 'TITLE_ENGLISH'),\n  titleNative('Title Native', 'TITLE_NATIVE');\n\n  const MediaSort(this.label, this.value);\n\n  final String label;\n  final String value;\n}\n\nenum EntrySort {\n  title('Title'),\n  titleDesc('Title'),\n  score('Score'),\n  scoreDesc('Score'),\n  updated('Updated'),\n  updatedDesc('Updated'),\n  added('Added'),\n  addedDesc('Added'),\n  airing('Airing'),\n  airingDesc('Airing'),\n  startedOn('Started'),\n  startedOnDesc('Started'),\n  completedOn('Completed'),\n  completedOnDesc('Completed'),\n  releasedOn('Released'),\n  releasedOnDesc('Released'),\n  progress('Progress'),\n  progressDesc('Progress'),\n  avgScore('Rating'),\n  avgScoreDesc('Rating'),\n  repeated('Repeats'),\n  repeatedDesc('Repeats');\n\n  const EntrySort(this.label);\n\n  final String label;\n\n  /// The API supports only few default sortings.\n  static const rowOrders = [scoreDesc, title, updatedDesc, addedDesc];\n\n  /// Serialize to API row order.\n  String toRowOrder() => switch (this) {\n    scoreDesc => 'score',\n    updatedDesc => 'updatedAt',\n    addedDesc => 'id',\n    title => 'title',\n    _ => 'title',\n  };\n\n  /// Deserialize from API row order.\n  static EntrySort fromRowOrder(String key) => switch (key) {\n    'score' => scoreDesc,\n    'updatedAt' => updatedDesc,\n    'id' => addedDesc,\n    'title' => title,\n    _ => title,\n  };\n}\n"
  },
  {
    "path": "lib/feature/media/media_overview_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/action_chip_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/discover/discover_filter_model.dart';\nimport 'package:otraku/feature/media/media_provider.dart';\nimport 'package:otraku/feature/tag/tag_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/table_list.dart';\nimport 'package:otraku/feature/discover/discover_filter_provider.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\n\nclass MediaOverviewSubview extends StatelessWidget {\n  const MediaOverviewSubview.asFragment({\n    required this.info,\n    required this.ref,\n    required this.highContrast,\n    required ScrollController this.scrollCtrl,\n  }) : header = null;\n\n  const MediaOverviewSubview.withHeader({\n    required this.info,\n    required this.ref,\n    required this.highContrast,\n    required Widget this.header,\n  }) : scrollCtrl = null;\n\n  final WidgetRef ref;\n  final MediaInfo info;\n  final Widget? header;\n  final ScrollController? scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    String? release;\n    if (info.startDate != null) {\n      if (info.endDate != null) {\n        if (info.startDate != info.endDate) {\n          release = '${info.startDate} - ${info.endDate}';\n        } else {\n          release = info.startDate!;\n        }\n      } else {\n        release = '${info.startDate} - ?';\n      }\n    }\n\n    final details = [\n      if (release != null) ('Release', release),\n      if (info.status != null) ('Status', info.status!.label),\n      if (info.episodes != null) ('Episodes', info.episodes!.toString()),\n      if (info.duration != null) ('Duration', info.duration!),\n      if (info.chapters != null) ('Chapters', info.chapters!.toString()),\n      if (info.volumes != null) ('Volumes', info.volumes!.toString()),\n      if (info.season != null) ('Season', info.season!),\n      if (info.source != null) ('Source', info.source!.label),\n      if (info.countryOfOrigin != null) ('Origin', info.countryOfOrigin!.label),\n    ];\n\n    final titles = [\n      if (info.hashtag != null) ('Hashtag', info.hashtag!),\n      if (info.romajiTitle != null) ('Romaji', info.romajiTitle!),\n      if (info.englishTitle != null) ('English', info.englishTitle!),\n      if (info.nativeTitle != null) ('Native', info.nativeTitle!),\n      ...info.synonyms.map((s) => ('Synonym', s)),\n    ];\n\n    const spacing = SliverToBoxAdapter(child: SizedBox(height: Theming.offset));\n    final mediaQuery = MediaQuery.of(context);\n    final refreshControl = SliverRefreshControl(\n      onRefresh: () => ref.invalidate(mediaProvider(info.id)),\n    );\n\n    return CustomScrollView(\n      controller: scrollCtrl,\n      physics: Theming.bouncyPhysics,\n      slivers: [\n        if (header != null) ...[\n          header!,\n          MediaQuery(\n            data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)),\n            child: refreshControl,\n          ),\n        ] else\n          refreshControl,\n        SliverPadding(\n          padding: const .symmetric(horizontal: Theming.offset),\n          sliver: SliverMainAxisGroup(\n            slivers: [\n              if (info.description.isNotEmpty) _Description(info.description, highContrast),\n              SliverToBoxAdapter(\n                child: CardExtension.highContrast(highContrast)(\n                  child: Padding(\n                    padding: Theming.paddingAll,\n                    child: Row(\n                      mainAxisAlignment: .spaceEvenly,\n                      children: [\n                        _IconTile(\n                          text: info.favourites.toString(),\n                          tooltip: 'Favorites',\n                          icon: Icons.favorite_outline_rounded,\n                        ),\n                        _IconTile(\n                          text: info.popularity.toString(),\n                          tooltip: 'Popularity',\n                          icon: Icons.person_outline_rounded,\n                        ),\n                        _IconTile(\n                          text: info.averageScore.toString(),\n                          tooltip: 'Weighted Average Score',\n                          icon: Icons.percent_rounded,\n                        ),\n                        _IconTile(\n                          text: info.meanScore.toString(),\n                          tooltip: 'Mean Score',\n                          icon: Ionicons.star_half_outline,\n                        ),\n                      ],\n                    ),\n                  ),\n                ),\n              ),\n              spacing,\n              SliverTableList(details, highContrast: highContrast),\n              if (info.genres.isNotEmpty)\n                _Wrap(\n                  title: 'Genres',\n                  children: info.genres\n                      .map((genre) => _buildGenreActionChip(context, genre, highContrast))\n                      .toList(),\n                ),\n              if (info.tags.isNotEmpty)\n                _TagsWrap(\n                  ref: ref,\n                  tags: info.tags,\n                  isAnime: info.isAnime,\n                  highContrast: highContrast,\n                ),\n              if (info.studios.isNotEmpty)\n                _Wrap(\n                  title: 'Studios',\n                  children: info.studios.entries\n                      .map(\n                        (studio) =>\n                            _buildStudioActionChip(context, studio.key, studio.value, highContrast),\n                      )\n                      .toList(),\n                ),\n              if (info.producers.isNotEmpty)\n                _Wrap(\n                  title: 'Producers',\n                  children: info.producers.entries\n                      .map(\n                        (studio) =>\n                            _buildStudioActionChip(context, studio.key, studio.value, highContrast),\n                      )\n                      .toList(),\n                ),\n              if (info.externalLinks.isNotEmpty)\n                _Wrap(\n                  title: 'External links',\n                  children: info.externalLinks\n                      .map((link) => _buildExternalLinkChip(context, link, highContrast))\n                      .toList(),\n                ),\n              spacing,\n              spacing,\n              SliverTableList(titles, highContrast: highContrast),\n            ],\n          ),\n        ),\n        SliverToBoxAdapter(\n          child: SizedBox(\n            height: MediaQuery.paddingOf(context).bottom + Theming.normalTapTarget + 26,\n          ),\n        ),\n      ],\n    );\n  }\n\n  Widget _buildGenreActionChip(BuildContext context, String genre, bool highContrast) {\n    return ActionChipExtension.highContrast(highContrast)(\n      label: Text(genre),\n      tooltip: 'Filter By Genre',\n      onPressed: () {\n        final notifier = ref.read(discoverFilterProvider.notifier);\n        final filter = notifier.state.copyWith(\n          type: info.isAnime ? .anime : .manga,\n          search: '',\n          mediaFilter: DiscoverMediaFilter(notifier.state.mediaFilter.sort),\n        )..mediaFilter.genreIn.add(genre);\n        notifier.state = filter;\n\n        context.go(Routes.home(.discover));\n      },\n    );\n  }\n\n  Widget _buildStudioActionChip(BuildContext context, String name, int id, bool highContrast) {\n    return ActionChipExtension.highContrast(highContrast)(\n      label: Text(name),\n      tooltip: 'Open Studio',\n      onPressed: () => context.push(Routes.studio(id, name)),\n    );\n  }\n\n  Widget _buildExternalLinkChip(BuildContext context, ExternalLink link, bool highContrast) {\n    return _Chip(\n      label: link.countryCode == null ? Text(link.site) : Text('${link.site} ${link.countryCode}'),\n      onTap: () => SnackBarExtension.launch(context, link.url),\n      onLongTap: () => SnackBarExtension.copy(context, link.url),\n      onTapHint: 'open external link',\n      onLongTapHint: 'copy external link',\n      highContrast: highContrast,\n      leading: Container(\n        width: 15,\n        height: 15,\n        decoration: BoxDecoration(borderRadius: Theming.borderRadiusSmall, color: link.color),\n      ),\n    );\n  }\n}\n\nclass _Description extends StatefulWidget {\n  const _Description(this.text, this.highContrast);\n\n  final String text;\n  final bool highContrast;\n\n  @override\n  State<_Description> createState() => _DescriptionState();\n}\n\nclass _DescriptionState extends State<_Description> {\n  bool _expanded = false;\n\n  @override\n  Widget build(BuildContext context) {\n    final content = _expanded\n        ? HtmlContent(widget.text)\n        : ShaderMask(\n            shaderCallback: (bounds) => const LinearGradient(\n              begin: Alignment(0.0, 0.3),\n              end: Alignment(0.0, 1.0),\n              colors: [Colors.white, Colors.transparent],\n            ).createShader(bounds),\n            child: ConstrainedBox(\n              constraints: const BoxConstraints(maxHeight: 72),\n              child: HtmlContent(widget.text),\n            ),\n          );\n\n    return SliverToBoxAdapter(\n      child: Padding(\n        padding: const .only(bottom: Theming.offset),\n        child: CardExtension.highContrast(widget.highContrast)(\n          child: InkWell(\n            borderRadius: Theming.borderRadiusSmall,\n            onTap: () => setState(() => _expanded = !_expanded),\n            onLongPress: () {\n              final text = widget.text.replaceAll(RegExp(r'<br>'), '');\n              SnackBarExtension.copy(context, text);\n            },\n            child: Padding(padding: const .all(Theming.offset), child: content),\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass _IconTile extends StatelessWidget {\n  const _IconTile({required this.text, required this.tooltip, required this.icon});\n\n  final String text;\n  final String tooltip;\n  final IconData icon;\n\n  @override\n  Widget build(BuildContext context) {\n    return Tooltip(\n      message: tooltip,\n      triggerMode: .tap,\n      child: Column(\n        mainAxisSize: .min,\n        spacing: 5,\n        children: [\n          Icon(icon, size: Theming.iconSmall, color: ColorScheme.of(context).onSurfaceVariant),\n          Text(text),\n        ],\n      ),\n    );\n  }\n}\n\nclass _Wrap extends StatelessWidget {\n  const _Wrap({required this.title, required this.children, this.trailingAction});\n\n  final String title;\n  final Widget? trailingAction;\n  final List<Widget> children;\n\n  @override\n  Widget build(BuildContext context) {\n    return SliverToBoxAdapter(\n      child: Column(\n        mainAxisSize: .min,\n        crossAxisAlignment: .stretch,\n        children: [\n          Row(\n            children: [\n              Expanded(child: Text(title)),\n              if (trailingAction != null)\n                trailingAction!\n              else\n                const SizedBox(height: Theming.minTapTarget),\n            ],\n          ),\n          Wrap(spacing: 5, children: children),\n        ],\n      ),\n    );\n  }\n}\n\nclass _TagsWrap extends StatefulWidget {\n  const _TagsWrap({\n    required this.ref,\n    required this.tags,\n    required this.isAnime,\n    required this.highContrast,\n  });\n\n  final WidgetRef ref;\n  final List<Tag> tags;\n  final bool isAnime;\n  final bool highContrast;\n\n  @override\n  State<_TagsWrap> createState() => __TagsWrapState();\n}\n\nclass __TagsWrapState extends State<_TagsWrap> {\n  bool? _showSpoilers;\n\n  @override\n  void initState() {\n    super.initState();\n    for (final t in widget.tags) {\n      if (t.isSpoiler) {\n        _showSpoilers = false;\n        break;\n      }\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final tags = _showSpoilers == null || _showSpoilers!\n        ? widget.tags\n        : widget.tags.where((t) => !t.isSpoiler).toList();\n\n    final spoilerColor = ColorScheme.of(context).error;\n\n    return _Wrap(\n      title: 'Tags',\n      trailingAction: _showSpoilers != null\n          ? IconButton(\n              icon: _showSpoilers!\n                  ? const Icon(Ionicons.eye_off_outline)\n                  : const Icon(Ionicons.eye_outline),\n              tooltip: _showSpoilers! ? 'Hide Spoilers' : 'Show Spoilers',\n              onPressed: () => setState(() => _showSpoilers = !_showSpoilers!),\n            )\n          : null,\n      children: tags.map((tag) => _buildTagChip(tag, spoilerColor)).toList(),\n    );\n  }\n\n  Widget _buildTagChip(Tag tag, Color spoilerColor) {\n    return _Chip(\n      label: Text(\n        '${tag.name} ${tag.rank}%',\n        style: tag.isSpoiler ? TextStyle(color: spoilerColor) : null,\n      ),\n      onTapHint: 'filter by this tag',\n      onLongTapHint: 'show tag description',\n      highContrast: widget.highContrast,\n      onTap: () {\n        final notifier = widget.ref.read(discoverFilterProvider.notifier);\n        final filter = notifier.state.copyWith(\n          type: widget.isAnime ? .anime : .manga,\n          search: '',\n          mediaFilter: DiscoverMediaFilter(notifier.state.mediaFilter.sort),\n        )..mediaFilter.tagIn.add(tag.name);\n        notifier.state = filter;\n\n        context.go(Routes.home(.discover));\n      },\n      onLongTap: () => showDialog(\n        context: context,\n        builder: (context) => TextDialog(title: tag.name, text: tag.desciption),\n      ),\n    );\n  }\n}\n\nclass _Chip extends StatelessWidget {\n  const _Chip({\n    required this.label,\n    required this.highContrast,\n    this.leading,\n    this.onTap,\n    this.onLongTap,\n    this.onTapHint,\n    this.onLongTapHint,\n  });\n\n  final Widget label;\n  final Widget? leading;\n  final void Function()? onTap;\n  final void Function()? onLongTap;\n  final String? onTapHint;\n  final String? onLongTapHint;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return MergeSemantics(\n      child: Semantics(\n        onTapHint: onTapHint,\n        onLongPressHint: onLongTapHint,\n        child: GestureDetector(\n          onLongPress: onLongTap,\n          child: ActionChipExtension.highContrast(highContrast)(\n            label: label,\n            avatar: leading,\n            onPressed: onTap,\n          ),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/extension/iterable_extension.dart';\nimport 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/feature/edit/edit_model.dart';\nimport 'package:otraku/feature/forum/forum_model.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/settings/settings_provider.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\nimport 'package:otraku/util/paged.dart';\n\nfinal mediaProvider = AsyncNotifierProvider.autoDispose.family<MediaNotifier, Media, int>(\n  MediaNotifier.new,\n);\n\nfinal mediaConnectionsProvider = AsyncNotifierProvider.autoDispose\n    .family<MediaRelationsNotifier, MediaConnections, int>(MediaRelationsNotifier.new);\n\nfinal mediaThreadsProvider =\n    AsyncNotifierProvider.family<MediaThreadsNotifier, Paged<ThreadItem>, int>(\n      MediaThreadsNotifier.new,\n    );\n\nfinal mediaFollowingProvider =\n    AsyncNotifierProvider.family<MediaFollowingNotifier, Paged<MediaFollowing>, int>(\n      MediaFollowingNotifier.new,\n    );\n\nclass MediaNotifier extends AsyncNotifier<Media> {\n  MediaNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Media> build() async {\n    var data = await ref.read(repositoryProvider).request(GqlQuery.media, {\n      'id': arg,\n      'withInfo': true,\n    });\n    data = data['Media'];\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    final relatedMedia = <RelatedMedia>[];\n    for (final relation in data['relations']['edges']) {\n      if (relation['node'] != null) {\n        relatedMedia.add(RelatedMedia(relation, imageQuality));\n      }\n    }\n\n    final settings = await ref.watch(settingsProvider.selectAsync((settings) => settings));\n\n    return Media(\n      EntryEdit(data, settings, false),\n      MediaInfo(data, imageQuality),\n      MediaStats(data),\n      relatedMedia,\n    );\n  }\n\n  Future<Object?> toggleFavorite() {\n    final value = state.value;\n    if (value == null) return Future.value('User not yet loaded');\n\n    final typeKey = value.info.isAnime ? 'anime' : 'manga';\n    return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, {\n      typeKey: arg,\n    }).getErrorOrNull();\n  }\n}\n\nclass MediaRelationsNotifier extends AsyncNotifier<MediaConnections> {\n  MediaRelationsNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<MediaConnections> build() => _fetch(const MediaConnections(), null);\n\n  Future<void> fetch(MediaTab tab) async {\n    final oldState = state.value ?? const MediaConnections();\n    state = switch (tab) {\n      .info || .relations || .threads || .following || .activities || .statistics => state,\n      .characters =>\n        oldState.characters.hasNext ? await AsyncValue.guard(() => _fetch(oldState, tab)) : state,\n      .staff =>\n        oldState.staff.hasNext ? await AsyncValue.guard(() => _fetch(oldState, tab)) : state,\n      .reviews =>\n        oldState.reviews.hasNext ? await AsyncValue.guard(() => _fetch(oldState, tab)) : state,\n      .recommendations =>\n        oldState.recommendations.hasNext\n            ? await AsyncValue.guard(() => _fetch(oldState, tab))\n            : state,\n    };\n  }\n\n  Future<MediaConnections> _fetch(MediaConnections oldState, MediaTab? tab) async {\n    final variables = <String, dynamic>{'id': arg};\n    if (tab == null) {\n      variables['withRecommendations'] = true;\n      variables['withCharacters'] = true;\n      variables['withStaff'] = true;\n      variables['withReviews'] = true;\n    } else if (tab == .recommendations) {\n      variables['withRecommendations'] = true;\n      variables['page'] = oldState.recommendations.next;\n    } else if (tab == .characters) {\n      variables['withCharacters'] = true;\n      variables['page'] = oldState.characters.next;\n    } else if (tab == .staff) {\n      variables['withStaff'] = true;\n      variables['page'] = oldState.staff.next;\n    } else if (tab == .reviews) {\n      variables['withReviews'] = true;\n      variables['page'] = oldState.reviews.next;\n    }\n\n    var data = await ref.read(repositoryProvider).request(GqlQuery.media, variables);\n    data = data['Media'];\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    var characters = oldState.characters;\n    var staff = oldState.staff;\n    var reviews = oldState.reviews;\n    var recommendations = oldState.recommendations;\n    var languageToVoiceActors = [...oldState.languageToVoiceActors];\n    var selectedLanguage = oldState.selectedLanguage;\n\n    if (tab == null || tab == .characters) {\n      final map = data['characters'];\n      final items = <MediaRelatedItem>[];\n      for (final c in map['edges']) {\n        final role = StringExtension.tryNoScreamingSnakeCase(c['role']);\n        items.add(MediaRelatedItem(c['node'], role));\n\n        if (c['voiceActors'] == null) continue;\n\n        for (final va in c['voiceActors']) {\n          final l = StringExtension.tryNoScreamingSnakeCase(va['languageV2']);\n          if (l == null) continue;\n\n          var languageMapping = languageToVoiceActors.firstWhereOrNull((lm) => lm.language == l);\n\n          if (languageMapping == null) {\n            languageMapping = (language: l, voiceActors: {});\n            languageToVoiceActors.add(languageMapping);\n          }\n\n          final characterVoiceActors = languageMapping.voiceActors.putIfAbsent(\n            items.last.id,\n            () => [],\n          );\n\n          characterVoiceActors.add(MediaRelatedItem(va, l));\n        }\n      }\n\n      languageToVoiceActors.sort((a, b) {\n        if (a.language == 'Japanese') return -1;\n        if (b.language == 'Japanese') return 1;\n        return a.language.compareTo(b.language);\n      });\n\n      characters = characters.withNext(items, map['pageInfo']['hasNextPage'] ?? false);\n    }\n\n    if (tab == null || tab == .staff) {\n      final map = data['staff'];\n      final items = <MediaRelatedItem>[];\n      for (final s in map['edges']) {\n        items.add(MediaRelatedItem(s['node'], s['role']));\n      }\n\n      staff = staff.withNext(items, map['pageInfo']['hasNextPage'] ?? false);\n    }\n\n    if (tab == null || tab == .reviews) {\n      final map = data['reviews'];\n      final items = <RelatedReview>[];\n      for (final r in map['nodes']) {\n        final item = RelatedReview.maybe(r);\n        if (item != null) items.add(item);\n      }\n\n      reviews = reviews.withNext(items, map['pageInfo']['hasNextPage'] ?? false);\n    }\n\n    if (tab == null || tab == .recommendations) {\n      final map = data['recommendations'];\n      final items = <Recommendation>[];\n      for (final r in map['nodes']) {\n        if (r['mediaRecommendation'] != null) {\n          items.add(Recommendation(r, imageQuality));\n        }\n      }\n\n      recommendations = recommendations.withNext(items, map['pageInfo']['hasNextPage'] ?? false);\n    }\n\n    return oldState.copyWith(\n      recommendations: recommendations,\n      characters: characters,\n      staff: staff,\n      reviews: reviews,\n      languageToVoiceActors: languageToVoiceActors,\n      selectedLanguage: selectedLanguage,\n    );\n  }\n\n  void changeLanguage(int selectedLanguage) => state.whenData((data) {\n    if (selectedLanguage >= data.languageToVoiceActors.length) return;\n\n    state = AsyncValue.data(\n      MediaConnections(\n        recommendations: data.recommendations,\n        characters: data.characters,\n        staff: data.staff,\n        reviews: data.reviews,\n        languageToVoiceActors: data.languageToVoiceActors,\n        selectedLanguage: selectedLanguage,\n      ),\n    );\n  });\n\n  Future<Object?> rateRecommendation(int recId, bool? rating) {\n    return ref.read(repositoryProvider).request(GqlMutation.rateRecommendation, {\n      'id': arg,\n      'recommendedId': recId,\n      'rating': rating == null\n          ? 'NO_RATING'\n          : rating\n          ? 'RATE_UP'\n          : 'RATE_DOWN',\n    }).getErrorOrNull();\n  }\n}\n\nclass MediaThreadsNotifier extends AsyncNotifier<Paged<ThreadItem>> {\n  MediaThreadsNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Paged<ThreadItem>> build() => _fetch(const Paged());\n\n  Future<void> fetch() async {\n    final oldState = state.value ?? const Paged();\n    if (!oldState.hasNext) return;\n    state = await AsyncValue.guard(() => _fetch(oldState));\n  }\n\n  Future<Paged<ThreadItem>> _fetch(Paged<ThreadItem> oldState) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.threadPage, {\n      'mediaId': arg,\n      'page': oldState.next,\n      'sort': 'ID_DESC',\n    });\n\n    final items = <ThreadItem>[];\n    for (final t in data['Page']['threads']) {\n      items.add(ThreadItem(t));\n    }\n\n    return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false);\n  }\n}\n\nclass MediaFollowingNotifier extends AsyncNotifier<Paged<MediaFollowing>> {\n  MediaFollowingNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Paged<MediaFollowing>> build() => _fetch(const Paged());\n\n  Future<void> fetch() async {\n    final oldState = state.value ?? const Paged();\n    if (!oldState.hasNext) return;\n    state = await AsyncValue.guard(() => _fetch(oldState));\n  }\n\n  Future<Paged<MediaFollowing>> _fetch(Paged<MediaFollowing> oldState) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.mediaFollowing, {\n      'mediaId': arg,\n      'page': oldState.next,\n    });\n\n    final items = <MediaFollowing>[];\n    for (final f in data['Page']['mediaList']) {\n      items.add(MediaFollowing(f));\n    }\n\n    return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false);\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_recommendations_view.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/media/media_provider.dart';\nimport 'package:otraku/widget/text_rail.dart';\n\nclass MediaRecommendationsSubview extends StatelessWidget {\n  const MediaRecommendationsSubview({\n    required this.id,\n    required this.scrollCtrl,\n    required this.rateRecommendation,\n    required this.highContrast,\n  });\n\n  final int id;\n  final ScrollController scrollCtrl;\n  final Future<Object?> Function(int, bool?) rateRecommendation;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView<Recommendation>(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(mediaConnectionsProvider(id)),\n      provider: mediaConnectionsProvider(\n        id,\n      ).select((s) => s.unwrapPrevious().whenData((data) => data.recommendations)),\n      onData: (data) => _MediaRecommendationsGrid(id, data.items, rateRecommendation, highContrast),\n    );\n  }\n}\n\nclass _MediaRecommendationsGrid extends StatelessWidget {\n  const _MediaRecommendationsGrid(\n    this.mediaId,\n    this.items,\n    this.rateRecommendation,\n    this.highContrast,\n  );\n\n  final int mediaId;\n  final List<Recommendation> items;\n  final Future<Object?> Function(int, bool?) rateRecommendation;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    if (items.isEmpty) {\n      return const SliverFillRemaining(child: Center(child: Text('No results')));\n    }\n\n    final textTheme = TextTheme.of(context);\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);\n    final tileHeight =\n        bodyMediumLineHeight * 2 + max(labelMediumLineHeight * 2, Theming.iconSmall) + 10;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 270, height: tileHeight),\n      delegate: SliverChildBuilderDelegate(childCount: items.length, (context, i) {\n        final textRailItems = <String, bool>{\n          if (items[i].entryStatus != null) items[i].entryStatus!.label(items[i].isAnime): true,\n          if (items[i].format != null) items[i].format!.label: false,\n          if (items[i].releaseYear != null) items[i].releaseYear!.toString(): false,\n        };\n\n        return CardExtension.highContrast(highContrast)(\n          child: MediaRouteTile(\n            id: items[i].id,\n            imageUrl: items[i].imageUrl,\n            child: Row(\n              children: [\n                Hero(\n                  tag: items[i].id,\n                  child: ClipRRect(\n                    borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n                    child: Container(\n                      color: ColorScheme.of(context).surfaceContainerHighest,\n                      child: CachedImage(\n                        items[i].imageUrl,\n                        width: tileHeight / Theming.coverHtoWRatio,\n                      ),\n                    ),\n                  ),\n                ),\n                Expanded(\n                  child: Padding(\n                    padding: const .symmetric(horizontal: Theming.offset, vertical: 5),\n                    child: Column(\n                      crossAxisAlignment: .start,\n                      mainAxisAlignment: .spaceAround,\n                      children: [\n                        Flexible(child: Text(items[i].title, overflow: .ellipsis, maxLines: 2)),\n                        TextRail(\n                          textRailItems,\n                          style: TextTheme.of(context).labelMedium,\n                          maxLines: 2,\n                        ),\n                      ],\n                    ),\n                  ),\n                ),\n                Padding(\n                  padding: const .symmetric(vertical: 5),\n                  child: const VerticalDivider(thickness: 1, width: 1),\n                ),\n                _RecommendationRating(mediaId, items[i], rateRecommendation),\n              ],\n            ),\n          ),\n        );\n      }),\n    );\n  }\n}\n\nclass _RecommendationRating extends StatefulWidget {\n  const _RecommendationRating(this.mediaId, this.item, this.rateRecommendation);\n\n  final int mediaId;\n  final Recommendation item;\n  final Future<Object?> Function(int, bool?) rateRecommendation;\n\n  @override\n  State<_RecommendationRating> createState() => _RecommendationRatingState();\n}\n\nclass _RecommendationRatingState extends State<_RecommendationRating> {\n  @override\n  Widget build(BuildContext context) {\n    final item = widget.item;\n\n    return Padding(\n      padding: const .symmetric(horizontal: Theming.offset, vertical: 5),\n      child: Column(\n        mainAxisAlignment: MainAxisAlignment.center,\n        spacing: Theming.offset,\n        children: [\n          Text(item.rating.toString()),\n          Row(\n            spacing: Theming.offset,\n            mainAxisAlignment: .spaceEvenly,\n            children: [\n              Tooltip(\n                message: 'Agree',\n                child: InkResponse(\n                  onTap: () async {\n                    final oldRating = item.rating;\n                    final oldUserRating = item.userRating;\n\n                    setState(() {\n                      switch (item.userRating) {\n                        case true:\n                          item.rating--;\n                          item.userRating = null;\n                          break;\n                        case false:\n                          item.rating += 2;\n                          item.userRating = true;\n                          break;\n                        case null:\n                          item.rating++;\n                          item.userRating = true;\n                          break;\n                      }\n                    });\n\n                    final err = await widget.rateRecommendation(item.id, item.userRating);\n                    if (err == null) return;\n\n                    setState(() {\n                      item.rating = oldRating;\n                      item.userRating = oldUserRating;\n                    });\n\n                    if (context.mounted) {\n                      SnackBarExtension.show(context, err.toString());\n                    }\n                  },\n                  child: item.userRating == true\n                      ? Icon(\n                          Icons.thumb_up,\n                          size: Theming.iconSmall,\n                          color: ColorScheme.of(context).primary,\n                        )\n                      : Icon(\n                          Icons.thumb_up_outlined,\n                          size: Theming.iconSmall,\n                          color: ColorScheme.of(context).onSurface,\n                        ),\n                ),\n              ),\n              Tooltip(\n                message: 'Disagree',\n                child: InkResponse(\n                  onTap: () async {\n                    final oldRating = item.rating;\n                    final oldUserRating = item.userRating;\n\n                    setState(() {\n                      switch (item.userRating) {\n                        case true:\n                          item.rating -= 2;\n                          item.userRating = false;\n                          break;\n                        case false:\n                          item.rating++;\n                          item.userRating = null;\n                          break;\n                        case null:\n                          item.rating--;\n                          item.userRating = false;\n                          break;\n                      }\n                    });\n\n                    final err = await widget.rateRecommendation(item.id, item.userRating);\n                    if (err == null) return;\n\n                    setState(() {\n                      item.rating = oldRating;\n                      item.userRating = oldUserRating;\n                    });\n\n                    if (context.mounted) {\n                      SnackBarExtension.show(context, err.toString());\n                    }\n                  },\n                  child: item.userRating == false\n                      ? Icon(\n                          Icons.thumb_down,\n                          size: Theming.iconSmall,\n                          color: ColorScheme.of(context).error,\n                        )\n                      : Icon(\n                          Icons.thumb_down_outlined,\n                          size: Theming.iconSmall,\n                          color: ColorScheme.of(context).onSurface,\n                        ),\n                ),\n              ),\n            ],\n          ),\n        ],\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_related_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/text_rail.dart';\nimport 'package:otraku/feature/media/media_models.dart';\n\nclass MediaRelatedSubview extends StatelessWidget {\n  const MediaRelatedSubview({\n    required this.relations,\n    required this.scrollCtrl,\n    required this.invalidate,\n    required this.highContrast,\n  });\n\n  final List<RelatedMedia> relations;\n  final ScrollController scrollCtrl;\n  final void Function() invalidate;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return ConstrainedView(\n      child: CustomScrollView(\n        controller: scrollCtrl,\n        physics: Theming.bouncyPhysics,\n        slivers: [\n          SliverRefreshControl(onRefresh: invalidate),\n          _MediaRelatedGrid(relations, highContrast),\n          const SliverFooter(),\n        ],\n      ),\n    );\n  }\n}\n\nclass _MediaRelatedGrid extends StatelessWidget {\n  const _MediaRelatedGrid(this.items, this.highContrast);\n\n  final List<RelatedMedia> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    if (items.isEmpty) {\n      return const SliverFillRemaining(child: Center(child: Text('No results')));\n    }\n\n    final textTheme = TextTheme.of(context);\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);\n    final tileHeight = bodyMediumLineHeight * 2 + labelMediumLineHeight * 2 + 25;\n    final coverWidth = tileHeight / Theming.coverHtoWRatio;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 270, height: tileHeight),\n      delegate: SliverChildBuilderDelegate(childCount: items.length, (context, i) {\n        final textRailItems = <String, bool>{\n          if (items[i].relationType != null) items[i].relationType!: true,\n          if (items[i].entryStatus != null) items[i].entryStatus!.label(items[i].isAnime): true,\n          if (items[i].format != null) items[i].format!.label: false,\n          if (items[i].releaseStatus != null) items[i].releaseStatus!: false,\n        };\n\n        return CardExtension.highContrast(highContrast)(\n          child: MediaRouteTile(\n            id: items[i].id,\n            imageUrl: items[i].imageUrl,\n            child: Row(\n              mainAxisAlignment: .start,\n              children: [\n                Hero(\n                  tag: items[i].id,\n                  child: ClipRRect(\n                    borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n                    child: Container(\n                      color: ColorScheme.of(context).surfaceContainerHighest,\n                      child: CachedImage(items[i].imageUrl, width: coverWidth),\n                    ),\n                  ),\n                ),\n                Expanded(\n                  child: Padding(\n                    padding: Theming.paddingAll,\n                    child: Column(\n                      mainAxisAlignment: .spaceEvenly,\n                      crossAxisAlignment: .start,\n                      spacing: 5,\n                      children: [\n                        Flexible(child: Text(items[i].title, overflow: .ellipsis, maxLines: 2)),\n                        TextRail(\n                          textRailItems,\n                          style: TextTheme.of(context).labelMedium,\n                          maxLines: 2,\n                        ),\n                      ],\n                    ),\n                  ),\n                ),\n              ],\n            ),\n          ),\n        );\n      }),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_reviews_view.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/media/media_provider.dart';\n\nclass MediaReviewsSubview extends StatelessWidget {\n  const MediaReviewsSubview({\n    required this.id,\n    required this.scrollCtrl,\n    required this.bannerUrl,\n    required this.highContrast,\n  });\n\n  final int id;\n  final ScrollController scrollCtrl;\n  final String? bannerUrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView<RelatedReview>(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(mediaConnectionsProvider(id)),\n      provider: mediaConnectionsProvider(\n        id,\n      ).select((s) => s.unwrapPrevious().whenData((data) => data.reviews)),\n      onData: (data) => _MediaReviewGrid(data.items, bannerUrl, highContrast),\n    );\n  }\n}\n\nclass _MediaReviewGrid extends StatelessWidget {\n  const _MediaReviewGrid(this.items, this.bannerUrl, this.highContrast);\n\n  final List<RelatedReview> items;\n  final String? bannerUrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    if (items.isEmpty) {\n      return const SliverFillRemaining(child: Center(child: Text('No results')));\n    }\n\n    const avatarSize = 50.0;\n    const verticalDivider = SizedBox(height: 20, child: VerticalDivider(thickness: 1, width: 20));\n\n    final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final tileHeight = max(avatarSize, bodyMediumLineHeight) + bodyMediumLineHeight * 3 + 25;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 300, height: tileHeight),\n      delegate: SliverChildBuilderDelegate(\n        childCount: items.length,\n        (context, i) => Column(\n          crossAxisAlignment: .start,\n          spacing: 5,\n          children: [\n            Row(\n              children: [\n                Expanded(\n                  child: GestureDetector(\n                    behavior: .opaque,\n                    onTap: () => context.push(Routes.user(items[i].userId, items[i].avatar)),\n                    child: Row(\n                      mainAxisSize: .min,\n                      spacing: Theming.offset,\n                      children: [\n                        ClipRRect(\n                          borderRadius: Theming.borderRadiusSmall,\n                          child: CachedImage(\n                            items[i].avatar,\n                            height: avatarSize,\n                            width: avatarSize,\n                          ),\n                        ),\n                        Flexible(child: Text(items[i].username, overflow: .ellipsis, maxLines: 1)),\n                      ],\n                    ),\n                  ),\n                ),\n                verticalDivider,\n                Tooltip(\n                  message: 'Reviewer Score',\n                  triggerMode: .tap,\n                  child: Row(\n                    mainAxisSize: .min,\n                    spacing: 5,\n                    children: [\n                      const Icon(Icons.star_half_rounded, size: Theming.iconSmall),\n                      Text(items[i].score.toString()),\n                    ],\n                  ),\n                ),\n                verticalDivider,\n                Tooltip(\n                  message: 'Review Rating',\n                  triggerMode: .tap,\n                  child: Row(\n                    mainAxisSize: .min,\n                    spacing: 5,\n                    children: [\n                      const Icon(Icons.thumb_up_outlined, size: Theming.iconSmall),\n                      Text(items[i].rating),\n                    ],\n                  ),\n                ),\n              ],\n            ),\n            Expanded(\n              child: GestureDetector(\n                behavior: .opaque,\n                onTap: () => context.push(Routes.review(items[i].reviewId, bannerUrl)),\n                child: CardExtension.highContrast(highContrast)(\n                  child: SizedBox(\n                    width: double.infinity,\n                    child: Padding(\n                      padding: Theming.paddingAll,\n                      child: Text(items[i].summary, overflow: .ellipsis, maxLines: 3),\n                    ),\n                  ),\n                ),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_route_tile.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/edit/edit_view.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass MediaRouteTile extends StatelessWidget {\n  const MediaRouteTile({super.key, required this.id, required this.imageUrl, required this.child});\n\n  final int id;\n  final String? imageUrl;\n  final Widget child;\n\n  @override\n  Widget build(BuildContext context) {\n    return InkWell(\n      borderRadius: Theming.borderRadiusSmall,\n      onTap: () => context.push(Routes.media(id, imageUrl)),\n      onLongPress: () => showSheet(context, EditView((id: id, setComplete: false))),\n      child: child,\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_staff_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/widget/grid/mono_relation_grid.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/feature/media/media_provider.dart';\n\nclass MediaStaffSubview extends StatelessWidget {\n  const MediaStaffSubview({required this.id, required this.scrollCtrl, required this.highContrast});\n\n  final int id;\n  final ScrollController scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView<MediaRelatedItem>(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(mediaConnectionsProvider(id)),\n      provider: mediaConnectionsProvider(\n        id,\n      ).select((s) => s.unwrapPrevious().whenData((data) => data.staff)),\n      onData: (data) => MonoRelationGrid(\n        items: data.items,\n        onTap: (item) => context.push(Routes.staff(item.tileId, item.tileImageUrl)),\n        highContrast: highContrast,\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_stats_view.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/discover/discover_filter_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/feature/discover/discover_filter_provider.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/statistics/charts.dart';\n\nclass MediaStatsSubview extends StatelessWidget {\n  const MediaStatsSubview({\n    required this.ref,\n    required this.info,\n    required this.stats,\n    required this.scrollCtrl,\n    required this.highContrast,\n  });\n\n  final WidgetRef ref;\n  final MediaInfo info;\n  final MediaStats stats;\n  final ScrollController scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return ConstrainedView(\n      child: CustomScrollView(\n        controller: scrollCtrl,\n        slivers: [\n          SliverToBoxAdapter(child: SizedBox(height: MediaQuery.paddingOf(context).top)),\n          if (stats.ranks.isNotEmpty)\n            _MediaRankGrid(ref: ref, info: info, highContrast: highContrast, ranks: stats.ranks),\n          if (stats.scoreNames.isNotEmpty)\n            SliverToBoxAdapter(\n              child: BarChart(\n                title: 'Score Distribution',\n                names: stats.scoreNames.map((n) => n.toString()).toList(),\n                values: stats.scoreValues,\n              ),\n            ),\n          if (stats.statusNames.isNotEmpty)\n            SliverToBoxAdapter(\n              child: Padding(\n                padding: const .only(top: Theming.offset),\n                child: SizedBox(\n                  height: 200,\n                  child: PieChart(\n                    title: 'Status Distribution',\n                    names: stats.statusNames,\n                    values: stats.statusValues,\n                    highContrast: highContrast,\n                  ),\n                ),\n              ),\n            ),\n          const SliverFooter(),\n        ],\n      ),\n    );\n  }\n}\n\nclass _MediaRankGrid extends StatelessWidget {\n  const _MediaRankGrid({\n    required this.ref,\n    required this.info,\n    required this.highContrast,\n    required this.ranks,\n  });\n\n  final WidgetRef ref;\n  final MediaInfo info;\n  final bool highContrast;\n  final List<MediaRank> ranks;\n\n  @override\n  Widget build(BuildContext context) {\n    final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final tileHeight = max(bodyMediumLineHeight * 2, Theming.iconBig) + 10;\n\n    return SliverPadding(\n      padding: const .symmetric(vertical: Theming.offset),\n      sliver: SliverGrid(\n        gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(\n          height: tileHeight,\n          minWidth: 185,\n        ),\n        delegate: SliverChildBuilderDelegate((_, i) {\n          return CardExtension.highContrast(highContrast)(\n            child: InkWell(\n              borderRadius: Theming.borderRadiusSmall,\n              onTap: () {\n                final notifier = ref.read(discoverFilterProvider.notifier);\n                final filter = notifier.state.copyWith(\n                  type: info.isAnime ? .anime : .manga,\n                  search: '',\n                  mediaFilter: DiscoverMediaFilter(notifier.state.mediaFilter.sort),\n                );\n\n                filter.mediaFilter.season = ranks[i].season;\n                filter.mediaFilter.startYearFrom = ranks[i].year;\n                filter.mediaFilter.startYearTo = ranks[i].year;\n                filter.mediaFilter.sort = ranks[i].typeIsScore ? .scoreDesc : .popularityDesc;\n                if (info.format != null) {\n                  if (info.isAnime) {\n                    filter.mediaFilter.animeFormats.add(info.format!);\n                  } else {\n                    filter.mediaFilter.mangaFormats.add(info.format!);\n                  }\n                }\n                notifier.state = filter;\n\n                context.go(Routes.home(.discover));\n              },\n              child: Padding(\n                padding: const .symmetric(horizontal: Theming.offset, vertical: 5),\n                child: Row(\n                  spacing: Theming.offset,\n                  children: [\n                    Icon(\n                      ranks[i].typeIsScore ? Ionicons.star : Icons.favorite_rounded,\n                      color: ColorScheme.of(context).onSurfaceVariant,\n                    ),\n                    Expanded(child: Text(ranks[i].text, overflow: .ellipsis, maxLines: 2)),\n                  ],\n                ),\n              ),\n            ),\n          );\n        }, childCount: ranks.length),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_threads_view.dart",
    "content": "import 'package:flutter/widgets.dart';\nimport 'package:otraku/feature/forum/thread_item_list.dart';\nimport 'package:otraku/feature/media/media_provider.dart';\nimport 'package:otraku/widget/paged_view.dart';\n\nclass MediaThreadsSubview extends StatelessWidget {\n  const MediaThreadsSubview({\n    required this.id,\n    required this.scrollCtrl,\n    required this.highContrast,\n    required this.analogClock,\n  });\n\n  final int id;\n  final ScrollController scrollCtrl;\n  final bool highContrast;\n  final bool analogClock;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(mediaThreadsProvider(id)),\n      provider: mediaThreadsProvider(id),\n      onData: (data) => ThreadItemList(data.items, highContrast, analogClock),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/media/media_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/scroll_controller_extension.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/feature/activity/activities_provider.dart';\nimport 'package:otraku/feature/media/media_activities_view.dart';\nimport 'package:otraku/feature/media/media_floating_actions.dart';\nimport 'package:otraku/feature/media/media_characters_view.dart';\nimport 'package:otraku/feature/media/media_following_view.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/media/media_provider.dart';\nimport 'package:otraku/feature/media/media_recommendations_view.dart';\nimport 'package:otraku/feature/media/media_related_view.dart';\nimport 'package:otraku/feature/media/media_reviews_view.dart';\nimport 'package:otraku/feature/media/media_staff_view.dart';\nimport 'package:otraku/feature/media/media_stats_view.dart';\nimport 'package:otraku/feature/media/media_threads_view.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/feature/media/media_overview_view.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/dual_pane_with_tab_bar.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/feature/media/media_header.dart';\n\nclass MediaView extends StatefulWidget {\n  const MediaView(this.id, this.coverUrl);\n\n  final int id;\n  final String? coverUrl;\n\n  @override\n  State<MediaView> createState() => _MediaViewState();\n}\n\nclass _MediaViewState extends State<MediaView> {\n  final _scrollCtrl = ScrollController();\n\n  @override\n  void dispose() {\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        ref.listen<AsyncValue>(mediaProvider(widget.id), (_, s) {\n          if (s.hasError) {\n            SnackBarExtension.show(context, 'Failed to load media: ${s.error}');\n          }\n        });\n\n        final media = ref.watch(mediaProvider(widget.id));\n\n        final toggleFavorite = () => ref.read(mediaProvider(widget.id).notifier).toggleFavorite();\n\n        return AdaptiveScaffold(\n          floatingAction: media.value != null\n              ? HidingFloatingActionButton(\n                  key: const Key('edit'),\n                  scrollCtrl: _scrollCtrl,\n                  child: MediaEditButton(media.value!),\n                )\n              : null,\n          child: switch (Theming.of(context).formFactor) {\n            .phone => _CompactView(\n              id: widget.id,\n              coverUrl: widget.coverUrl,\n              media: media,\n              scrollCtrl: _scrollCtrl,\n              toggleFavorite: toggleFavorite,\n            ),\n            .tablet => _LargeView(\n              id: widget.id,\n              coverUrl: widget.coverUrl,\n              ref: ref,\n              media: media,\n              scrollCtrl: _scrollCtrl,\n              toggleFavorite: toggleFavorite,\n            ),\n          },\n        );\n      },\n    );\n  }\n}\n\nclass _CompactView extends StatefulWidget {\n  const _CompactView({\n    required this.id,\n    required this.coverUrl,\n    required this.media,\n    required this.scrollCtrl,\n    required this.toggleFavorite,\n  });\n\n  final int id;\n  final String? coverUrl;\n  final AsyncValue<Media> media;\n  final ScrollController scrollCtrl;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  State<_CompactView> createState() => _CompactViewState();\n}\n\nclass _CompactViewState extends State<_CompactView> with SingleTickerProviderStateMixin {\n  late final _tabCtrl = TabController(length: MediaHeader.tabsWithOverview.length, vsync: this);\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final mediaQuery = MediaQuery.of(context);\n\n    final header = MediaHeader.withTabBar(\n      id: widget.id,\n      coverUrl: widget.coverUrl,\n      media: widget.media.value,\n      tabCtrl: _tabCtrl,\n      scrollToTop: widget.scrollCtrl.scrollToTop,\n      toggleFavorite: widget.toggleFavorite,\n    );\n\n    return NestedScrollView(\n      controller: widget.scrollCtrl,\n      headerSliverBuilder: (context, _) => [header],\n      body: MediaQuery(\n        data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)),\n        child: widget.media.unwrapPrevious().when(\n          loading: () => const Center(child: Loader()),\n          error: (_, _) => const Center(child: Text('Failed to load media')),\n          data: (data) => _MediaTabs.withOverview(id: widget.id, media: data, tabCtrl: _tabCtrl),\n        ),\n      ),\n    );\n  }\n}\n\nclass _LargeView extends StatefulWidget {\n  const _LargeView({\n    required this.id,\n    required this.coverUrl,\n    required this.ref,\n    required this.media,\n    required this.scrollCtrl,\n    required this.toggleFavorite,\n  });\n\n  final int id;\n  final String? coverUrl;\n  final WidgetRef ref;\n  final AsyncValue<Media> media;\n  final ScrollController scrollCtrl;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  State<_LargeView> createState() => _LargeViewState();\n}\n\nclass _LargeViewState extends State<_LargeView> with SingleTickerProviderStateMixin {\n  late final _tabCtrl = TabController(length: MediaHeader.tabsWithoutOverview.length, vsync: this);\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final options = widget.ref.read(persistenceProvider.select((s) => s.options));\n\n    final header = MediaHeader.withoutTabBar(\n      id: widget.id,\n      coverUrl: widget.coverUrl,\n      media: widget.media.value,\n      toggleFavorite: widget.toggleFavorite,\n    );\n\n    return DualPaneWithTabBar(\n      tabCtrl: _tabCtrl,\n      scrollToTop: widget.scrollCtrl.scrollToTop,\n      tabs: MediaHeader.tabsWithoutOverview,\n      leftPane: widget.media.unwrapPrevious().when(\n        loading: () => CustomScrollView(\n          physics: Theming.bouncyPhysics,\n          slivers: [\n            header,\n            const SliverFillRemaining(child: Center(child: Loader())),\n          ],\n        ),\n        error: (_, _) => CustomScrollView(\n          physics: Theming.bouncyPhysics,\n          slivers: [\n            header,\n            const SliverFillRemaining(child: Center(child: Text('Failed to load media'))),\n          ],\n        ),\n        data: (data) => MediaOverviewSubview.withHeader(\n          ref: widget.ref,\n          info: data.info,\n          header: header,\n          highContrast: options.highContrast,\n        ),\n      ),\n      rightPane: widget.media.unwrapPrevious().maybeWhen(\n        data: (data) => _MediaTabs.withoutOverview(\n          id: widget.id,\n          media: data,\n          tabCtrl: _tabCtrl,\n          scrollCtrl: widget.scrollCtrl,\n        ),\n        orElse: () => const SizedBox(),\n      ),\n    );\n  }\n}\n\n/// When [withOverview], [_MediaTabs] requires a [NestedScrollView] ancestor.\n///\n/// Due to [NestedScrollView] limitations, the custom [PagedController]\n/// can't be used here and has to be reimplemented temporarely on the inner\n/// scroll controller of the [NestedScrollView].\n/// For more context: https://github.com/flutter/flutter/pull/104166.\nclass _MediaTabs extends ConsumerStatefulWidget {\n  const _MediaTabs.withOverview({required this.id, required this.media, required this.tabCtrl})\n    : withOverview = true,\n      scrollCtrl = null;\n\n  const _MediaTabs.withoutOverview({\n    required this.id,\n    required this.media,\n    required this.tabCtrl,\n    required ScrollController this.scrollCtrl,\n  }) : withOverview = false;\n\n  final int id;\n  final Media media;\n  final TabController tabCtrl;\n  final ScrollController? scrollCtrl;\n  final bool withOverview;\n\n  @override\n  ConsumerState<_MediaTabs> createState() => __MediaSubViewState();\n}\n\nclass __MediaSubViewState extends ConsumerState<_MediaTabs> {\n  late final _mediaActivitiesTag = MediaActivitiesTag(widget.id);\n  late final ScrollController _scrollCtrl;\n  double _lastMaxExtent = 0;\n\n  @override\n  void initState() {\n    super.initState();\n    _scrollCtrl =\n        widget.scrollCtrl ??\n        context.findAncestorStateOfType<NestedScrollViewState>()!.innerController;\n\n    _scrollCtrl.addListener(_scrollListener);\n    widget.tabCtrl.addListener(_tabListener);\n  }\n\n  @override\n  void deactivate() {\n    // These pages are lazy-loaded and then kept alive until the media page is popped.\n    ref.invalidate(mediaThreadsProvider(widget.id));\n    ref.invalidate(mediaFollowingProvider(widget.id));\n    ref.invalidate(activitiesProvider(_mediaActivitiesTag));\n    super.deactivate();\n  }\n\n  @override\n  void dispose() {\n    _scrollCtrl.removeListener(_scrollListener);\n    widget.tabCtrl.removeListener(_tabListener);\n    super.dispose();\n  }\n\n  void _tabListener() {\n    _lastMaxExtent = 0;\n\n    // This is a workaround for an issue with [NestedScrollView].\n    // If you switch to a tab with pagination, where the content\n    // doesn't fill the view, the scroll controller has it's maximum\n    // extent set to 0 and the loading of a next page of items is not triggered.\n    // This is why we need to manually load the second page.\n    if (!widget.tabCtrl.indexIsChanging && _scrollCtrl.hasClients) {\n      final pos = _scrollCtrl.positions.last;\n      if (pos.minScrollExtent == pos.maxScrollExtent) _loadNextPage();\n    }\n  }\n\n  void _scrollListener() {\n    final pos = _scrollCtrl.positions.last;\n    if (pos.pixels < pos.maxScrollExtent - 100) return;\n    if (_lastMaxExtent == pos.maxScrollExtent) return;\n\n    _lastMaxExtent = pos.maxScrollExtent;\n    _loadNextPage();\n  }\n\n  void _loadNextPage() {\n    final index = widget.withOverview ? widget.tabCtrl.index : widget.tabCtrl.index + 1;\n\n    if (index == MediaTab.threads.index) {\n      ref.read(mediaThreadsProvider(widget.id).notifier).fetch();\n    } else if (index == MediaTab.following.index) {\n      ref.read(mediaFollowingProvider(widget.id).notifier).fetch();\n    } else if (index == MediaTab.activities.index) {\n      ref.read(activitiesProvider(_mediaActivitiesTag).notifier).fetch();\n    } else {\n      ref\n          .read(mediaConnectionsProvider(widget.id).notifier)\n          .fetch(MediaTab.values.elementAt(index));\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    ref.watch(mediaConnectionsProvider(widget.id).select((_) => null));\n\n    final viewerId = ref.watch(viewerIdProvider);\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    return TabBarView(\n      controller: widget.tabCtrl,\n      children: [\n        if (widget.withOverview)\n          ConstrainedView(\n            padded: false,\n            child: MediaOverviewSubview.asFragment(\n              ref: ref,\n              info: widget.media.info,\n              scrollCtrl: _scrollCtrl,\n              highContrast: options.highContrast,\n            ),\n          ),\n        MediaRelatedSubview(\n          relations: widget.media.related,\n          scrollCtrl: _scrollCtrl,\n          invalidate: () => ref.invalidate(mediaProvider(widget.id)),\n          highContrast: options.highContrast,\n        ),\n        MediaCharactersSubview(\n          id: widget.id,\n          scrollCtrl: _scrollCtrl,\n          highContrast: options.highContrast,\n        ),\n        MediaStaffSubview(\n          id: widget.id,\n          scrollCtrl: _scrollCtrl,\n          highContrast: options.highContrast,\n        ),\n        MediaReviewsSubview(\n          id: widget.id,\n          scrollCtrl: _scrollCtrl,\n          bannerUrl: widget.media.info.banner,\n          highContrast: options.highContrast,\n        ),\n        MediaThreadsSubview(\n          id: widget.id,\n          scrollCtrl: _scrollCtrl,\n          highContrast: options.highContrast,\n          analogClock: options.analogClock,\n        ),\n        MediaFollowingSubview(\n          id: widget.id,\n          scrollCtrl: _scrollCtrl,\n          highContrast: options.highContrast,\n        ),\n        MediaActivitiesSubview(\n          ref: ref,\n          tag: _mediaActivitiesTag,\n          scrollCtrl: _scrollCtrl,\n          viewerId: viewerId,\n          options: options,\n        ),\n        MediaRecommendationsSubview(\n          id: widget.id,\n          scrollCtrl: _scrollCtrl,\n          rateRecommendation: ref\n              .read(mediaConnectionsProvider(widget.id).notifier)\n              .rateRecommendation,\n          highContrast: options.highContrast,\n        ),\n        MediaStatsSubview(\n          ref: ref,\n          info: widget.media.info,\n          stats: widget.media.stats,\n          scrollCtrl: _scrollCtrl,\n          highContrast: options.highContrast,\n        ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/notification/notifications_filter_model.dart",
    "content": "enum NotificationsFilter {\n  all('All'),\n  replies('Replies'),\n  activity('Activity'),\n  forum('Forum'),\n  airing('Airing'),\n  follows('Follows'),\n  media('Media');\n\n  const NotificationsFilter(this.label);\n\n  final String label;\n\n  List<String>? get vars => switch (this) {\n    NotificationsFilter.all => null,\n    NotificationsFilter.replies => const [\n      'ACTIVITY_MESSAGE',\n      'ACTIVITY_REPLY',\n      'ACTIVITY_REPLY_SUBSCRIBED',\n      'ACTIVITY_MENTION',\n      'THREAD_COMMENT_REPLY',\n      'THREAD_COMMENT_MENTION',\n      'THREAD_SUBSCRIBED',\n    ],\n    NotificationsFilter.activity => const [\n      'ACTIVITY_MESSAGE',\n      'ACTIVITY_REPLY',\n      'ACTIVITY_REPLY_SUBSCRIBED',\n      'ACTIVITY_MENTION',\n      'ACTIVITY_LIKE',\n      'ACTIVITY_REPLY_LIKE',\n    ],\n    NotificationsFilter.forum => const [\n      'THREAD_COMMENT_REPLY',\n      'THREAD_COMMENT_MENTION',\n      'THREAD_SUBSCRIBED',\n      'THREAD_LIKE',\n      'THREAD_COMMENT_LIKE',\n    ],\n    NotificationsFilter.airing => const ['AIRING'],\n    NotificationsFilter.follows => const ['FOLLOWING'],\n    NotificationsFilter.media => const [\n      'RELATED_MEDIA_ADDITION',\n      'MEDIA_DATA_CHANGE',\n      'MEDIA_MERGE',\n      'MEDIA_DELETION',\n    ],\n  };\n}\n"
  },
  {
    "path": "lib/feature/notification/notifications_filter_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/notification/notifications_filter_model.dart';\n\nfinal notificationsFilterProvider =\n    NotifierProvider.autoDispose<NotificationsFilterNotifier, NotificationsFilter>(\n      NotificationsFilterNotifier.new,\n    );\n\nclass NotificationsFilterNotifier extends Notifier<NotificationsFilter> {\n  @override\n  NotificationsFilter build() => NotificationsFilter.all;\n\n  @override\n  NotificationsFilter get state => super.state;\n\n  @override\n  set state(NotificationsFilter newState) => super.state = newState;\n}\n"
  },
  {
    "path": "lib/feature/notification/notifications_model.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/extension/iterable_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\n\nenum NotificationType {\n  following('Follows', 'FOLLOWING'),\n  activityMention('Activity mentions', 'ACTIVITY_MENTION'),\n  activityMessage('Messages', 'ACTIVITY_MESSAGE'),\n  activityLike('Activity likes', 'ACTIVITY_LIKE'),\n  activityReply('Activity replies', 'ACTIVITY_REPLY'),\n  acrivityReplyLike('Activity reply likes', 'ACTIVITY_REPLY_LIKE'),\n  activityReplySubscribed('Subscribed activity replies', 'ACTIVITY_REPLY_SUBSCRIBED'),\n  threadLike('Thread likes', 'THREAD_LIKE'),\n  threadReplySubscribed('Subscribed thread replies', 'THREAD_SUBSCRIBED'),\n  threadCommentMention('Thread mentions', 'THREAD_COMMENT_MENTION'),\n  threadCommentReply('Thread comments', 'THREAD_COMMENT_REPLY'),\n  threadCommentLike('Thread comment likes', 'THREAD_COMMENT_LIKE'),\n  airing('Episode releases', 'AIRING'),\n  relatedMediaAddition('Related media additions', 'RELATED_MEDIA_ADDITION'),\n  mediaDataChange('Media changes', 'MEDIA_DATA_CHANGE'),\n  mediaMerge('Media merges', 'MEDIA_MERGE'),\n  mediaDeletion('Media deletions', 'MEDIA_DELETION'),\n  mediaSubmissionUpdate('Media submission updates', 'MEDIA_SUBMISSION_UPDATE'),\n  staffSubmissionUpdate('Staff submission updates', 'STAFF_SUBMISSION_UPDATE'),\n  characterSubmissionUpdate('Character submission updates', 'CHARACTER_SUBMISSION_UPDATE');\n\n  const NotificationType(this.label, this.value);\n\n  final String label;\n  final String value;\n\n  static NotificationType? from(String? value) =>\n      NotificationType.values.firstWhereOrNull((v) => v.value == value);\n}\n\nsealed class SiteNotification {\n  SiteNotification({\n    required Map<String, dynamic> map,\n    required this.type,\n    required this.imageUrl,\n    required this.texts,\n  }) : id = map['id'],\n       createdAt = DateTimeExtension.fromSecondsSinceEpoch(map['createdAt'] ?? 0);\n\n  static SiteNotification? maybe(Map<String, dynamic> map, ImageQuality imageQuality) {\n    final type = NotificationType.from(map['type']);\n\n    return switch (type) {\n      null => null,\n      .following => FollowNotification(map, type),\n      .activityMention ||\n      .activityMessage ||\n      .activityLike ||\n      .activityReply ||\n      .acrivityReplyLike ||\n      .activityReplySubscribed => ActivityNotification(map, type),\n      .threadLike => ThreadNotification(map, type),\n      .threadReplySubscribed ||\n      .threadCommentMention ||\n      .threadCommentReply ||\n      .threadCommentLike => ThreadCommentNotification(map, type),\n      .airing || .relatedMediaAddition => MediaReleaseNotification(map, type, imageQuality),\n      .mediaDataChange || .mediaMerge => MediaChangeNotification(map, type, imageQuality),\n      .mediaDeletion => MediaDeletionNotification(map, type),\n      .mediaSubmissionUpdate => MediaSubmissionUpdateNotification(map, imageQuality),\n      .characterSubmissionUpdate => CharacterSubmissionUpdateNotification(map, imageQuality),\n      .staffSubmissionUpdate => StaffSubmissionUpdateNotification(map, imageQuality),\n    };\n  }\n\n  final int id;\n  final NotificationType type;\n  final DateTime createdAt;\n  final String? imageUrl;\n  final List<String> texts;\n}\n\nclass FollowNotification extends SiteNotification {\n  FollowNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required this.userId,\n  });\n\n  factory FollowNotification(Map<String, dynamic> map, NotificationType type) =>\n      FollowNotification._(\n        map: map,\n        type: type,\n        imageUrl: map['user']?['avatar']?['large'],\n        texts: [map['user']?['name'] ?? '?', ' followed you'],\n        userId: map['user']?['id'] ?? 0,\n      );\n\n  final int userId;\n}\n\nclass ActivityNotification extends SiteNotification {\n  ActivityNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required this.userId,\n    required this.activityId,\n  });\n\n  factory ActivityNotification(Map<String, dynamic> map, NotificationType type) {\n    final List<String> texts = switch (type) {\n      .activityMention => [map['user']?['name'] ?? '?', ' mentioned you in an activity'],\n      .activityMessage => [map['user']?['name'] ?? '?', ' sent you a message'],\n      .activityLike => [map['user']?['name'] ?? '?', ' liked your activity'],\n      .activityReply => [map['user']?['name'] ?? '?', ' replied to your activity'],\n      .acrivityReplyLike => [map['user']?['name'] ?? '?', ' liked your reply'],\n      .activityReplySubscribed => [\n        map['user']?['name'] ?? '?',\n        ' replied to a subscribed activity',\n      ],\n      _ => const [],\n    };\n\n    return ActivityNotification._(\n      map: map,\n      type: type,\n      imageUrl: map['user']?['avatar']?['large'],\n      texts: texts,\n      userId: map['user']?['id'] ?? 0,\n      activityId: map['activityId'] ?? 0,\n    );\n  }\n\n  final int userId;\n  final int activityId;\n}\n\nclass ThreadNotification extends SiteNotification {\n  ThreadNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required this.userId,\n    required this.threadId,\n    required this.threadSiteUrl,\n  });\n\n  factory ThreadNotification(Map<String, dynamic> map, NotificationType type) =>\n      ThreadNotification._(\n        map: map,\n        type: type,\n        imageUrl: map['user']?['avatar']?['large'],\n        texts: [map['user']?['name'] ?? '?', ' liked your thread ', map['thread']?['title'] ?? ''],\n        userId: map['user']?['id'] ?? 0,\n        threadId: map['thread']?['id'] ?? 0,\n        threadSiteUrl: map['thread']?['siteUrl'],\n      );\n\n  final int userId;\n  final int threadId;\n  final String? threadSiteUrl;\n}\n\nclass ThreadCommentNotification extends SiteNotification {\n  ThreadCommentNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required this.userId,\n    required this.commentId,\n    required this.commentSiteUrl,\n  });\n\n  factory ThreadCommentNotification(Map<String, dynamic> map, NotificationType type) {\n    final List<String> texts = switch (type) {\n      .threadReplySubscribed => [\n        map['user']?['name'] ?? '?',\n        if (map['thread']?['title'] != null) ...[\n          ' commented in ',\n          map['thread']['title'],\n        ] else\n          ' commented in a subscribed thread',\n      ],\n      .threadCommentMention => [\n        map['user']?['name'] ?? '?',\n        if (map['thread']?['title'] != null) ...[\n          ' mentioned you in ',\n          map['thread']['title'],\n        ] else\n          ' mentioned you in a subscribed thread',\n      ],\n      .threadCommentReply => [\n        map['user']?['name'] ?? '?',\n        if (map['thread']?['title'] != null) ...[\n          ' replied to your comment in ',\n          map['thread']['title'],\n        ] else\n          ' replied to your comment in a subscribed thread',\n      ],\n      .threadCommentLike => [\n        map['user']?['name'] ?? '?',\n        if (map['thread']?['title'] != null) ...[\n          ' liked your comment in ',\n          map['thread']['title'],\n        ] else\n          ' liked your comment in a subscribed thread',\n      ],\n      _ => const [],\n    };\n\n    return ThreadCommentNotification._(\n      map: map,\n      type: type,\n      imageUrl: map['user']?['avatar']?['large'],\n      texts: texts,\n      userId: map['user']?['id'] ?? 0,\n      commentId: map['comment']?['id'] ?? 0,\n      commentSiteUrl: map['comment']?['siteUrl'],\n    );\n  }\n\n  final int userId;\n  final int commentId;\n  final String? commentSiteUrl;\n}\n\nclass MediaReleaseNotification extends SiteNotification {\n  MediaReleaseNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required this.mediaId,\n  });\n\n  factory MediaReleaseNotification(\n    Map<String, dynamic> map,\n    NotificationType type,\n    ImageQuality imageQuality,\n  ) {\n    final List<String> texts = switch (type) {\n      .airing => [\n        map['media']?['title']?['userPreferred'] ?? '?',\n        ' episode ',\n        map['episode']?.toString() ?? '?',\n        ' aired',\n      ],\n      .relatedMediaAddition => [\n        map['media']?['title']?['userPreferred'] ?? '?',\n        ' got added to the site',\n      ],\n      _ => const [],\n    };\n\n    return MediaReleaseNotification._(\n      map: map,\n      type: type,\n      imageUrl: map['media']?['coverImage']?[imageQuality.value],\n      texts: texts,\n      mediaId: map['media']?['id'] ?? 0,\n    );\n  }\n\n  final int mediaId;\n}\n\nclass MediaChangeNotification extends SiteNotification {\n  MediaChangeNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required this.mediaId,\n    required this.reason,\n  });\n\n  factory MediaChangeNotification(\n    Map<String, dynamic> map,\n    NotificationType type,\n    ImageQuality imageQuality,\n  ) {\n    final List<String> texts = switch (type) {\n      .mediaDataChange => [\n        map['media']?['title']?['userPreferred'] ?? '?',\n        ' got site data changes',\n      ],\n      .mediaMerge => [\n        List<String>.from(map['deletedMediaTitles'] ?? const [], growable: false).join(\", \"),\n        ' got merged into ',\n        map['media']?['title']?['userPreferred'] ?? '?',\n      ],\n      _ => const [],\n    };\n\n    return MediaChangeNotification._(\n      map: map,\n      type: type,\n      imageUrl: map['media']?['coverImage']?[imageQuality.value],\n      texts: texts,\n      mediaId: map['media']?['id'] ?? 0,\n      reason: map['reason'] ?? '',\n    );\n  }\n\n  final int mediaId;\n  final String reason;\n}\n\nclass MediaDeletionNotification extends SiteNotification {\n  MediaDeletionNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required this.reason,\n  });\n\n  factory MediaDeletionNotification(Map<String, dynamic> map, NotificationType type) =>\n      MediaDeletionNotification._(\n        map: map,\n        type: type,\n        imageUrl: null,\n        texts: [map['deletedMediaTitle'] ?? '?', ' got deleted from the site'],\n        reason: map['reason'] ?? '',\n      );\n\n  final String reason;\n}\n\nsealed class SubmissionUpdateNotification extends SiteNotification {\n  SubmissionUpdateNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required this.itemId,\n  }) : notes = map['notes'] ?? '';\n\n  final int? itemId;\n  final String notes;\n}\n\nclass MediaSubmissionUpdateNotification extends SubmissionUpdateNotification {\n  MediaSubmissionUpdateNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required super.itemId,\n  }) : super._();\n\n  factory MediaSubmissionUpdateNotification(Map<String, dynamic> map, ImageQuality imageQuality) =>\n      MediaSubmissionUpdateNotification._(\n        map: map,\n        type: .mediaSubmissionUpdate,\n        imageUrl: map['media']?['coverImage']?[imageQuality.value],\n        texts: [\n          map['submittedTitle'] ?? map['media']?['title']?['userPreferred'] ?? '?',\n          ' - submission ',\n          map['status'] ?? '?',\n        ],\n        itemId: map['media']?['id'],\n      );\n}\n\nclass CharacterSubmissionUpdateNotification extends SubmissionUpdateNotification {\n  CharacterSubmissionUpdateNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required super.itemId,\n  }) : super._();\n\n  factory CharacterSubmissionUpdateNotification(\n    Map<String, dynamic> map,\n    ImageQuality imageQuality,\n  ) => CharacterSubmissionUpdateNotification._(\n    map: map,\n    type: .characterSubmissionUpdate,\n    imageUrl: map['character']?['image']?[imageQuality.personValue],\n    texts: [\n      map['character']?['name']?['userPreferred'] ?? '?',\n      ' - submission ',\n      map['status'] ?? '?',\n    ],\n    itemId: map['character']?['id'],\n  );\n}\n\nclass StaffSubmissionUpdateNotification extends SubmissionUpdateNotification {\n  StaffSubmissionUpdateNotification._({\n    required super.map,\n    required super.type,\n    required super.imageUrl,\n    required super.texts,\n    required super.itemId,\n  }) : super._();\n\n  factory StaffSubmissionUpdateNotification(Map<String, dynamic> map, ImageQuality imageQuality) =>\n      StaffSubmissionUpdateNotification._(\n        map: map,\n        type: .staffSubmissionUpdate,\n        imageUrl: map['staff']?['image']?[imageQuality.personValue],\n        texts: [\n          map['staff']?['name']?['userPreferred'] ?? '?',\n          ' - submission ',\n          map['status'] ?? '?',\n        ],\n        itemId: map['staff']?['id'],\n      );\n}\n"
  },
  {
    "path": "lib/feature/notification/notifications_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/notification/notifications_filter_model.dart';\nimport 'package:otraku/feature/notification/notifications_filter_provider.dart';\nimport 'package:otraku/feature/notification/notifications_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal notificationsProvider =\n    AsyncNotifierProvider.autoDispose<NotificationsNotifier, PagedWithTotal<SiteNotification>>(\n      NotificationsNotifier.new,\n    );\n\nclass NotificationsNotifier extends AsyncNotifier<PagedWithTotal<SiteNotification>> {\n  late NotificationsFilter filter;\n\n  @override\n  FutureOr<PagedWithTotal<SiteNotification>> build() async {\n    filter = ref.watch(notificationsFilterProvider);\n    return await _fetch(const PagedWithTotal());\n  }\n\n  Future<void> fetch() async {\n    final oldState = state.value ?? const PagedWithTotal();\n    if (!oldState.hasNext) return;\n    state = await AsyncValue.guard(() => _fetch(oldState));\n  }\n\n  Future<PagedWithTotal<SiteNotification>> _fetch(PagedWithTotal<SiteNotification> oldState) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.notifications, {\n      'page': oldState.next,\n      if (filter == NotificationsFilter.all) ...{\n        'withCount': true,\n        'resetCount': true,\n      } else\n        'filter': filter.vars,\n    });\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    int? unreadCount;\n    if (filter.index < 1) {\n      unreadCount = data['Viewer']['unreadNotificationCount'] ?? 0;\n    }\n\n    final items = <SiteNotification>[];\n    for (final n in data['Page']['notifications']) {\n      final item = SiteNotification.maybe(n, imageQuality);\n      if (item != null) items.add(item);\n    }\n\n    return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false, unreadCount);\n  }\n}\n"
  },
  {
    "path": "lib/feature/notification/notifications_view.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/notification/notifications_filter_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/feature/notification/notifications_filter_provider.dart';\nimport 'package:otraku/feature/notification/notifications_model.dart';\nimport 'package:otraku/feature/notification/notifications_provider.dart';\nimport 'package:otraku/util/background_handler.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/feature/edit/edit_view.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/input/pill_selector.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/widget/timestamp.dart';\n\nclass NotificationsView extends ConsumerStatefulWidget {\n  const NotificationsView();\n\n  @override\n  ConsumerState<NotificationsView> createState() => _NotificationsViewState();\n}\n\nclass _NotificationsViewState extends ConsumerState<NotificationsView> {\n  late final _scrollCtrl = PagedController(\n    loadMore: () => ref.read(notificationsProvider.notifier).fetch(),\n  );\n\n  @override\n  void initState() {\n    super.initState();\n    BackgroundHandler.clearNotifications();\n  }\n\n  @override\n  void dispose() {\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final unreadCount = ref.watch(notificationsProvider.select((s) => s.value?.total ?? 0));\n\n    final filter = ref.watch(notificationsFilterProvider);\n\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    final content = _Content(\n      unreadCount: unreadCount,\n      analogClock: options.analogClock,\n      highContrast: options.highContrast,\n      scrollCtrl: _scrollCtrl,\n    );\n\n    final formFactor = Theming.of(context).formFactor;\n\n    return AdaptiveScaffold(\n      topBar: const TopBar(title: 'Notifications'),\n      floatingAction: formFactor == .phone\n          ? HidingFloatingActionButton(\n              key: const Key('filter'),\n              scrollCtrl: _scrollCtrl,\n              child: FloatingActionButton(\n                tooltip: 'Filter',\n                onPressed: _showFilterSheet,\n                child: const Icon(Ionicons.funnel_outline),\n              ),\n            )\n          : null,\n      child: formFactor == .phone\n          ? content\n          : Row(\n              children: [\n                PillSelector(\n                  selected: filter.index,\n                  maxWidth: 120,\n                  onTap: (i) => ref.read(notificationsFilterProvider.notifier).state =\n                      NotificationsFilter.values[i],\n                  items: NotificationsFilter.values.map((v) => Text(v.label)).toList(),\n                ),\n                Expanded(child: content),\n              ],\n            ),\n    );\n  }\n\n  void _showFilterSheet() {\n    showSheet(\n      context,\n      Consumer(\n        builder: (context, ref, _) {\n          final index = ref.read(notificationsFilterProvider.notifier).state.index;\n\n          return SimpleSheet(\n            initialHeight: PillSelector.expectedMinHeight(NotificationsFilter.values.length),\n            builder: (context, scrollCtrl) => PillSelector(\n              scrollCtrl: scrollCtrl,\n              selected: index,\n              onTap: (i) {\n                ref.read(notificationsFilterProvider.notifier).state =\n                    NotificationsFilter.values[i];\n                Navigator.pop(context);\n              },\n              items: NotificationsFilter.values.map((v) => Text(v.label)).toList(),\n            ),\n          );\n        },\n      ),\n    );\n  }\n}\n\nclass _Content extends StatelessWidget {\n  const _Content({\n    required this.unreadCount,\n    required this.analogClock,\n    required this.highContrast,\n    required this.scrollCtrl,\n  });\n\n  final int unreadCount;\n  final bool analogClock;\n  final bool highContrast;\n  final ScrollController scrollCtrl;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView<SiteNotification>(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(notificationsProvider),\n      provider: notificationsProvider,\n      onData: (data) => SliverList(\n        delegate: SliverChildBuilderDelegate(\n          (context, i) =>\n              _NotificationItem(data.items[i], i < unreadCount, analogClock, highContrast),\n          childCount: data.items.length,\n        ),\n      ),\n    );\n  }\n}\n\nclass _NotificationItem extends StatelessWidget {\n  const _NotificationItem(this.item, this.unread, this.analogClock, this.highContrast);\n\n  final SiteNotification item;\n  final bool unread;\n  final bool analogClock;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final textTheme = TextTheme.of(context);\n    final bodyMediumStyle = textTheme.bodyMedium!;\n    final accentedStyle = bodyMediumStyle.copyWith(color: ColorScheme.of(context).primary);\n\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelSmallLineHeight = context.lineHeight(textTheme.labelSmall!);\n    final height = bodyMediumLineHeight * 2 + max(labelSmallLineHeight, Theming.iconSmall) + 23;\n\n    return SizedBox(\n      height: height + 10,\n      child: CardExtension.highContrast(highContrast)(\n        margin: const .only(bottom: Theming.offset),\n        child: Row(\n          children: [\n            if (item.imageUrl != null)\n              GestureDetector(\n                behavior: .opaque,\n                onTap: () => switch (item) {\n                  FollowNotification item => context.push(Routes.user(item.userId, item.imageUrl)),\n                  ActivityNotification item => context.push(\n                    Routes.user(item.userId, item.imageUrl),\n                  ),\n                  ThreadNotification item => context.push(Routes.user(item.userId, item.imageUrl)),\n                  ThreadCommentNotification item => context.push(\n                    Routes.user(item.userId, item.imageUrl),\n                  ),\n                  MediaReleaseNotification item => context.push(\n                    Routes.media(item.mediaId, item.imageUrl),\n                  ),\n                  MediaChangeNotification item => context.push(\n                    Routes.media(item.mediaId, item.imageUrl),\n                  ),\n                  MediaDeletionNotification _ => null,\n                  MediaSubmissionUpdateNotification item =>\n                    item.itemId != null ? context.push(Routes.media(item.itemId!)) : null,\n                  CharacterSubmissionUpdateNotification item =>\n                    item.itemId != null ? context.push(Routes.character(item.itemId!)) : null,\n                  StaffSubmissionUpdateNotification item =>\n                    item.itemId != null ? context.push(Routes.staff(item.itemId!)) : null,\n                },\n                onLongPress: () => switch (item) {\n                  MediaReleaseNotification item => showSheet(\n                    context,\n                    EditView((id: item.mediaId, setComplete: false)),\n                  ),\n                  MediaChangeNotification item => showSheet(\n                    context,\n                    EditView((id: item.mediaId, setComplete: false)),\n                  ),\n                  _ => null,\n                },\n                child: ClipRRect(\n                  borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n                  child: CachedImage(item.imageUrl!, width: height / Theming.coverHtoWRatio),\n                ),\n              ),\n            Flexible(\n              child: GestureDetector(\n                behavior: .opaque,\n                onTap: () => switch (item) {\n                  FollowNotification item => context.push(Routes.user(item.userId, item.imageUrl)),\n                  ActivityNotification item => context.push(Routes.activity(item.activityId)),\n                  ThreadNotification item => context.push(Routes.thread(item.threadId)),\n                  ThreadCommentNotification item => context.push(Routes.comment(item.commentId)),\n                  MediaReleaseNotification item => context.push(\n                    Routes.media(item.mediaId, item.imageUrl),\n                  ),\n                  MediaChangeNotification _ ||\n                  MediaDeletionNotification _ ||\n                  MediaSubmissionUpdateNotification _ ||\n                  CharacterSubmissionUpdateNotification _ ||\n                  StaffSubmissionUpdateNotification _ => showDialog(\n                    context: context,\n                    builder: (context) => _NotificationDialog(item),\n                  ),\n                },\n                onLongPress: () => switch (item) {\n                  MediaReleaseNotification item => showSheet(\n                    context,\n                    EditView((id: item.mediaId, setComplete: false)),\n                  ),\n                  MediaChangeNotification item => showSheet(\n                    context,\n                    EditView((id: item.mediaId, setComplete: false)),\n                  ),\n                  _ => null,\n                },\n                child: Padding(\n                  padding: Theming.paddingAll,\n                  child: Column(\n                    mainAxisAlignment: .spaceEvenly,\n                    crossAxisAlignment: .stretch,\n                    spacing: 3,\n                    children: [\n                      Flexible(\n                        child: Text.rich(\n                          overflow: .ellipsis,\n                          maxLines: 2,\n                          TextSpan(\n                            children: [\n                              for (int i = 0; i < item.texts.length; i++)\n                                TextSpan(\n                                  text: item.texts[i],\n                                  style: (i % 2 == 0) ? accentedStyle : bodyMediumStyle,\n                                ),\n                            ],\n                          ),\n                        ),\n                      ),\n                      Timestamp(item.createdAt, analogClock),\n                    ],\n                  ),\n                ),\n              ),\n            ),\n            if (unread)\n              Container(\n                height: height,\n                width: Theming.offset,\n                decoration: BoxDecoration(\n                  color: ColorScheme.of(context).primary,\n                  borderRadius: const BorderRadius.horizontal(right: Theming.radiusSmall),\n                ),\n              ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n\nclass _NotificationDialog extends StatelessWidget {\n  const _NotificationDialog(this.item);\n\n  final SiteNotification item;\n\n  @override\n  Widget build(BuildContext context) {\n    final bodyMediumStyle = TextTheme.of(context).bodyMedium!;\n    final accentedStyle = bodyMediumStyle.copyWith(color: ColorScheme.of(context).primary);\n    final imageHeight = context.lineHeight(bodyMediumStyle) * 6;\n\n    return DialogBox(\n      Padding(\n        padding: const EdgeInsetsGeometry.symmetric(\n          vertical: Theming.offset,\n          horizontal: Theming.offset * 2,\n        ),\n        child: Column(\n          mainAxisSize: .min,\n          crossAxisAlignment: .stretch,\n          spacing: Theming.offset,\n          children: [\n            if (item.imageUrl != null)\n              Center(\n                child: ClipRRect(\n                  borderRadius: Theming.borderRadiusSmall,\n                  child: CachedImage(\n                    item.imageUrl!,\n                    height: imageHeight,\n                    width: imageHeight / Theming.coverHtoWRatio,\n                  ),\n                ),\n              ),\n            Text.rich(\n              overflow: .ellipsis,\n              TextSpan(\n                children: [\n                  for (int i = 0; i < item.texts.length; i++)\n                    TextSpan(\n                      text: item.texts[i],\n                      style: (i % 2 == 0) ? accentedStyle : bodyMediumStyle,\n                    ),\n                ],\n              ),\n            ),\n            ?switch (item) {\n              MediaChangeNotification item => HtmlContent(item.reason),\n              MediaDeletionNotification item => HtmlContent(item.reason),\n              SubmissionUpdateNotification item => Text(item.notes),\n              _ => null,\n            },\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/review/review_grid.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/review/review_models.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\n\nclass ReviewGrid extends StatelessWidget {\n  const ReviewGrid(this.items, this.highContrast);\n\n  final List<ReviewItem> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final labelMediumLineHeight = context.lineHeight(TextTheme.of(context).labelMedium!);\n    final detailsHeight = max(\n      labelMediumLineHeight * 2,\n      labelMediumLineHeight + Theming.iconSmall + 5,\n    );\n    final textHeight = bodyMediumLineHeight * 2 + detailsHeight + 15;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(\n        minWidth: 270,\n        height: textHeight + 100,\n      ),\n      delegate: SliverChildBuilderDelegate(\n        (_, i) => _Tile(items[i], highContrast),\n        childCount: items.length,\n      ),\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile(this.item, this.highContrast);\n\n  final ReviewItem item;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return CardExtension.highContrast(highContrast)(\n      child: InkWell(\n        borderRadius: Theming.borderRadiusSmall,\n        onTap: () => context.push(Routes.review(item.id, item.bannerUrl)),\n        child: Column(\n          crossAxisAlignment: .stretch,\n          children: [\n            SizedBox(\n              height: 100,\n              child: ClipRRect(\n                borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall),\n                child: item.bannerUrl != null\n                    ? Hero(tag: item.id, child: CachedImage(item.bannerUrl!))\n                    : DecoratedBox(\n                        decoration: BoxDecoration(\n                          color: ColorScheme.of(context).surfaceContainerHighest,\n                        ),\n                      ),\n              ),\n            ),\n            Expanded(\n              child: Padding(\n                padding: .symmetric(horizontal: Theming.offset, vertical: 5),\n                child: Column(\n                  crossAxisAlignment: .stretch,\n                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,\n                  spacing: 5,\n                  children: [\n                    Text(\n                      'Review of ${item.mediaTitle} by ${item.userName}',\n                      style: TextTheme.of(context).bodyMedium,\n                      overflow: .ellipsis,\n                      maxLines: 2,\n                    ),\n                    Row(\n                      mainAxisAlignment: .spaceBetween,\n                      children: [\n                        Expanded(\n                          child: Text(\n                            item.summary,\n                            style: TextTheme.of(context).labelMedium,\n                            overflow: .ellipsis,\n                            maxLines: 2,\n                          ),\n                        ),\n                        Padding(\n                          padding: const .symmetric(horizontal: 5),\n                          child: Column(\n                            mainAxisAlignment: .center,\n                            spacing: 5,\n                            children: [\n                              const Icon(Icons.thumb_up_outlined, size: Theming.iconSmall),\n                              Text(item.rating, style: TextTheme.of(context).labelMedium),\n                            ],\n                          ),\n                        ),\n                      ],\n                    ),\n                  ],\n                ),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/review/review_header.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/review/review_models.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/widget/layout/content_header.dart';\n\nclass ReviewHeader extends StatelessWidget {\n  const ReviewHeader({required this.id, required this.review, required this.bannerUrl});\n\n  final int id;\n  final Review? review;\n  final String? bannerUrl;\n\n  @override\n  Widget build(BuildContext context) {\n    return CustomContentHeader(\n      title: review?.mediaTitle,\n      siteUrl: review?.siteUrl,\n      bannerUrl: review?.banner ?? bannerUrl,\n      content: PreferredSize(\n        preferredSize: const Size.fromHeight(100),\n        child: Column(\n          crossAxisAlignment: .stretch,\n          children: review != null\n              ? [\n                  Flexible(\n                    child: GestureDetector(\n                      onTap: () => context.push(Routes.media(review!.mediaId, review!.mediaCover)),\n                      child: Text(\n                        review!.mediaTitle,\n                        overflow: .fade,\n                        textAlign: .center,\n                        style: TextTheme.of(context).bodyMedium,\n                      ),\n                    ),\n                  ),\n                  Flexible(\n                    child: GestureDetector(\n                      behavior: .opaque,\n                      onTap: () => context.push(Routes.user(review!.userId, review!.userAvatar)),\n                      child: Text.rich(\n                        textAlign: .center,\n                        TextSpan(\n                          style: TextTheme.of(context).bodyMedium,\n                          children: [\n                            TextSpan(text: 'review by ', style: TextTheme.of(context).labelMedium),\n                            TextSpan(text: review!.userName),\n                          ],\n                        ),\n                      ),\n                    ),\n                  ),\n                ]\n              : const [],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/review/review_models.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/util/markdown.dart';\nimport 'package:otraku/feature/media/media_models.dart';\n\nclass ReviewItem {\n  ReviewItem._({\n    required this.id,\n    required this.mediaTitle,\n    required this.userName,\n    required this.summary,\n    required this.rating,\n    required this.bannerUrl,\n  });\n\n  factory ReviewItem(Map<String, dynamic> map) => ReviewItem._(\n    id: map['id'],\n    mediaTitle: map['media']['title']['userPreferred'],\n    userName: map['user']['name'],\n    summary: map['summary'],\n    rating: '${map['rating']}/${map['ratingAmount']}',\n    bannerUrl: map['media']['bannerImage'],\n  );\n\n  final int id;\n  final String mediaTitle;\n  final String userName;\n  final String summary;\n  final String rating;\n  final String? bannerUrl;\n}\n\nclass Review {\n  Review._({\n    required this.id,\n    required this.userId,\n    required this.mediaId,\n    required this.userName,\n    required this.userAvatar,\n    required this.mediaTitle,\n    required this.mediaCover,\n    required this.banner,\n    required this.summary,\n    required this.text,\n    required this.createdAt,\n    required this.siteUrl,\n    required this.score,\n    required this.rating,\n    required this.totalRating,\n    required this.viewerRating,\n  });\n\n  factory Review(Map<String, dynamic> map, ImageQuality imageQuality, bool analogClock) => Review._(\n    id: map['id'],\n    userId: map['user']['id'],\n    mediaId: map['media']['id'],\n    userName: map['user']['name'] ?? '',\n    userAvatar: map['user']['avatar']['large'],\n    mediaTitle: map['media']['title']['userPreferred'] ?? '',\n    mediaCover: map['media']['coverImage'][imageQuality.value],\n    banner: map['media']['bannerImage'],\n    summary: map['summary'] ?? '',\n    text: parseMarkdown(map['body'] ?? ''),\n    createdAt: DateTimeExtension.fromSecondsSinceEpoch(\n      map['createdAt'],\n    ).formattedDateTimeFromSeconds(analogClock),\n    siteUrl: map['siteUrl'],\n    score: map['score'] ?? 0,\n    rating: map['rating'] ?? 0,\n    totalRating: map['ratingAmount'] ?? 0,\n    viewerRating: map['userRating'] == 'UP_VOTE'\n        ? true\n        : map['userRating'] == 'DOWN_VOTE'\n        ? false\n        : null,\n  );\n\n  final int id;\n  final int userId;\n  final int mediaId;\n  final String userName;\n  final String? userAvatar;\n  final String mediaTitle;\n  final String? mediaCover;\n  final String? banner;\n  final String summary;\n  final String text;\n  final String createdAt;\n  final String siteUrl;\n  final int score;\n  int rating;\n  int totalRating;\n  bool? viewerRating;\n}\n\nclass ReviewsFilter {\n  const ReviewsFilter({this.mediaType, this.sort = .createdAtDesc});\n\n  final MediaType? mediaType;\n  final ReviewsSort sort;\n\n  ReviewsFilter copyWith({(MediaType?,)? mediaType, ReviewsSort? sort}) => ReviewsFilter(\n    mediaType: mediaType == null ? this.mediaType : mediaType.$1,\n    sort: sort ?? this.sort,\n  );\n}\n\nenum ReviewsSort {\n  createdAtDesc('Newest', 'CREATED_AT_DESC'),\n  createdAt('Oldest', 'CREATED_AT'),\n  ratingDesc('Highest Rated', 'RATING_DESC'),\n  rating('Lowest Rated', 'RATING');\n\n  const ReviewsSort(this.label, this.value);\n\n  final String label;\n  final String value;\n}\n"
  },
  {
    "path": "lib/feature/review/review_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/feature/review/review_models.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal reviewProvider = AsyncNotifierProvider.autoDispose.family<ReviewNotifier, Review, int>(\n  ReviewNotifier.new,\n);\n\nclass ReviewNotifier extends AsyncNotifier<Review> {\n  ReviewNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Review> build() async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.review, {'id': arg});\n\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    return Review(data['Review'], options.imageQuality, options.analogClock);\n  }\n\n  Future<Object?> rate(bool? rating) {\n    return ref.read(repositoryProvider).request(GqlMutation.rateReview, {\n      'id': arg,\n      'rating': rating == null\n          ? 'NO_VOTE'\n          : rating\n          ? 'UP_VOTE'\n          : 'DOWN_VOTE',\n    }).getErrorOrNull();\n  }\n}\n"
  },
  {
    "path": "lib/feature/review/review_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/feature/review/review_header.dart';\nimport 'package:otraku/feature/review/review_models.dart';\nimport 'package:otraku/feature/review/review_provider.dart';\nimport 'package:otraku/widget/html_content.dart';\n\nclass ReviewView extends StatelessWidget {\n  const ReviewView(this.id, this.bannerUrl);\n\n  final int id;\n  final String? bannerUrl;\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      body: Consumer(\n        builder: (context, ref, _) {\n          final data = ref.watch(reviewProvider(id).select((s) => s.value));\n\n          return CustomScrollView(\n            slivers: [\n              ReviewHeader(id: id, review: data, bannerUrl: bannerUrl),\n              if (data != null) ...[\n                SliverConstrainedView(\n                  sliver: SliverToBoxAdapter(\n                    child: Text(\n                      data.summary,\n                      style: TextTheme.of(context).labelMedium,\n                      textAlign: .center,\n                    ),\n                  ),\n                ),\n                SliverConstrainedView(\n                  sliver: HtmlContent(data.text, renderMode: RenderMode.sliverList),\n                ),\n                SliverToBoxAdapter(\n                  child: Center(\n                    child: Container(\n                      margin: Theming.paddingAll,\n                      padding: Theming.paddingAll,\n                      decoration: BoxDecoration(\n                        color: ColorScheme.of(context).primary,\n                        borderRadius: Theming.borderRadiusBig,\n                      ),\n                      child: Text(\n                        '${data.score}/100',\n                        style: TextTheme.of(\n                          context,\n                        ).bodyMedium?.copyWith(color: ColorScheme.of(context).onPrimary),\n                      ),\n                    ),\n                  ),\n                ),\n                _RateButtons(data, ref.read(reviewProvider(id).notifier).rate),\n                SliverPadding(\n                  padding: .only(\n                    top: 20,\n                    bottom: MediaQuery.viewPaddingOf(context).bottom + Theming.offset,\n                  ),\n                  sliver: SliverToBoxAdapter(\n                    child: Text(\n                      data.createdAt,\n                      style: TextTheme.of(context).labelMedium,\n                      textAlign: .center,\n                    ),\n                  ),\n                ),\n              ],\n            ],\n          );\n        },\n      ),\n    );\n  }\n}\n\nclass _RateButtons extends StatefulWidget {\n  const _RateButtons(this.review, this.rate);\n\n  final Review review;\n  final Future<Object?> Function(bool?) rate;\n\n  @override\n  _RateButtonsState createState() => _RateButtonsState();\n}\n\nclass _RateButtonsState extends State<_RateButtons> {\n  @override\n  Widget build(BuildContext context) {\n    final review = widget.review;\n\n    return SliverToBoxAdapter(\n      child: Column(\n        mainAxisSize: .min,\n        children: [\n          Row(\n            mainAxisAlignment: .center,\n            children: [\n              IconButton(\n                icon: Icon(review.viewerRating == true ? Icons.thumb_up : Icons.thumb_up_outlined),\n                color: review.viewerRating == true ? ColorScheme.of(context).primary : null,\n                onPressed: () => _rate(review.viewerRating != true ? true : null),\n              ),\n              IconButton(\n                icon: Icon(\n                  review.viewerRating == false ? Icons.thumb_down : Icons.thumb_down_outlined,\n                ),\n                color: review.viewerRating == false ? ColorScheme.of(context).error : null,\n                onPressed: () => _rate(review.viewerRating != false ? false : null),\n              ),\n            ],\n          ),\n          Text(\n            '${review.rating}/${review.totalRating} users liked this review',\n            style: TextTheme.of(context).labelMedium,\n            textAlign: .center,\n          ),\n        ],\n      ),\n    );\n  }\n\n  void _rate(bool? rating) async {\n    final review = widget.review;\n    final oldRating = review.rating;\n    final oldTotalRating = review.totalRating;\n    final oldViewerRating = review.viewerRating;\n\n    setState(() {\n      if (rating == null) {\n        if (oldViewerRating == true) {\n          review.rating--;\n        }\n        review.totalRating--;\n      } else if (rating) {\n        if (oldViewerRating == null) {\n          review.totalRating++;\n        }\n        review.rating++;\n      } else {\n        if (oldViewerRating == null) {\n          review.totalRating++;\n        } else {\n          review.rating--;\n        }\n      }\n\n      review.viewerRating = rating;\n    });\n\n    final err = await widget.rate(rating);\n    if (err == null) return;\n\n    setState(() {\n      review.rating = oldRating;\n      review.totalRating = oldTotalRating;\n      review.viewerRating = oldViewerRating;\n    });\n    if (mounted) SnackBarExtension.show(context, err.toString());\n  }\n}\n"
  },
  {
    "path": "lib/feature/review/reviews_filter_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/review/review_models.dart';\n\nfinal reviewsFilterProvider = NotifierProvider.autoDispose\n    .family<ReviewsFilterNotifier, ReviewsFilter, int>(ReviewsFilterNotifier.new);\n\nclass ReviewsFilterNotifier extends Notifier<ReviewsFilter> {\n  ReviewsFilterNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  ReviewsFilter build() => const ReviewsFilter();\n\n  @override\n  set state(ReviewsFilter newState) => super.state = newState;\n}\n"
  },
  {
    "path": "lib/feature/review/reviews_filter_sheet.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/review/review_models.dart';\n\nFuture<void> showReviewsFilterSheet({\n  required BuildContext context,\n  required ReviewsFilter filter,\n  required void Function(ReviewsFilter) onDone,\n  required bool highContrast,\n}) {\n  return showSheet(\n    context,\n    SimpleSheet(\n      initialHeight: Theming.minTapTarget * 3.5,\n      builder: (context, scrollCtrl) => ListView(\n        controller: scrollCtrl,\n        physics: Theming.bouncyPhysics,\n        padding: const .symmetric(horizontal: Theming.offset, vertical: 20),\n        children: [\n          ChipSelector.ensureSelected(\n            title: 'Sort',\n            items: ReviewsSort.values.map((v) => (v.label, v)).toList(),\n            value: filter.sort,\n            onChanged: (v) => filter = filter.copyWith(sort: v),\n            highContrast: highContrast,\n          ),\n          ChipSelector(\n            title: 'Media Type',\n            items: MediaType.values.map((v) => (v.label, v)).toList(),\n            value: filter.mediaType,\n            onChanged: (v) => filter = filter.copyWith(mediaType: (v,)),\n            highContrast: highContrast,\n          ),\n        ],\n      ),\n    ),\n  ).then((_) => onDone(filter));\n}\n"
  },
  {
    "path": "lib/feature/review/reviews_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\nimport 'package:otraku/feature/review/review_models.dart';\nimport 'package:otraku/feature/review/reviews_filter_provider.dart';\n\nfinal reviewsProvider = AsyncNotifierProvider.autoDispose\n    .family<ReviewsNotifier, PagedWithTotal<ReviewItem>, int>(ReviewsNotifier.new);\n\nclass ReviewsNotifier extends AsyncNotifier<PagedWithTotal<ReviewItem>> {\n  ReviewsNotifier(this.arg);\n\n  final int arg;\n\n  late ReviewsFilter filter;\n\n  @override\n  FutureOr<PagedWithTotal<ReviewItem>> build() {\n    filter = ref.watch(reviewsFilterProvider(arg));\n    return _fetch(const PagedWithTotal());\n  }\n\n  Future<void> fetch() async {\n    final oldState = state.value ?? const PagedWithTotal();\n    if (!oldState.hasNext) return;\n    state = await AsyncValue.guard(() => _fetch(oldState));\n  }\n\n  Future<PagedWithTotal<ReviewItem>> _fetch(PagedWithTotal<ReviewItem> oldState) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.reviewPage, {\n      'userId': arg,\n      'page': oldState.next,\n      'sort': filter.sort.value,\n      if (filter.mediaType != null) 'mediaType': filter.mediaType!.value,\n    });\n\n    final items = <ReviewItem>[];\n    for (final r in data['Page']['reviews']) {\n      items.add(ReviewItem(r));\n    }\n\n    return oldState.withNext(\n      items,\n      data['Page']['pageInfo']['hasNextPage'] ?? false,\n      data['Page']['pageInfo']['total'] ?? oldState.total,\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/review/reviews_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/review/review_models.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/feature/review/review_grid.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/feature/review/reviews_filter_sheet.dart';\nimport 'package:otraku/feature/review/reviews_provider.dart';\nimport 'package:otraku/feature/review/reviews_filter_provider.dart';\n\nclass ReviewsView extends ConsumerStatefulWidget {\n  const ReviewsView(this.id);\n\n  final int id;\n\n  @override\n  ConsumerState<ReviewsView> createState() => _ReviewsViewState();\n}\n\nclass _ReviewsViewState extends ConsumerState<ReviewsView> {\n  late final _ctrl = PagedController(\n    loadMore: () => ref.read(reviewsProvider(widget.id).notifier).fetch(),\n  );\n\n  @override\n  void dispose() {\n    _ctrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n    final count = ref.watch(reviewsProvider(widget.id).select((s) => s.value?.total ?? 0));\n\n    return AdaptiveScaffold(\n      topBar: TopBar(\n        title: 'Reviews',\n        trailing: [\n          if (count > 0)\n            Padding(\n              padding: const .only(right: Theming.offset),\n              child: Text(count.toString(), style: TextTheme.of(context).titleSmall),\n            ),\n        ],\n      ),\n      floatingAction: HidingFloatingActionButton(\n        key: const Key('filter'),\n        scrollCtrl: _ctrl,\n        child: FloatingActionButton(\n          tooltip: 'Filter',\n          child: const Icon(Ionicons.funnel_outline),\n          onPressed: () => showReviewsFilterSheet(\n            context: context,\n            filter: ref.read(reviewsFilterProvider(widget.id)),\n            onDone: (filter) => ref.read(reviewsFilterProvider(widget.id).notifier).state = filter,\n            highContrast: options.highContrast,\n          ),\n        ),\n      ),\n      child: PagedView<ReviewItem>(\n        scrollCtrl: _ctrl,\n        onRefresh: (invalidate) => invalidate(reviewsProvider(widget.id)),\n        provider: reviewsProvider(widget.id),\n        onData: (data) => ReviewGrid(data.items, options.highContrast),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/settings/settings_about_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\n\nclass SettingsAboutSubview extends StatelessWidget {\n  const SettingsAboutSubview(this.scrollCtrl);\n\n  final ScrollController scrollCtrl;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        final padding = MediaQuery.paddingOf(context);\n        final persistence = ref.watch(persistenceProvider);\n        final lastBackgroundJob = persistence.appMeta.lastBackgroundJob;\n        final lastJobTimestamp = lastBackgroundJob?.formattedDateTimeFromSeconds(\n          persistence.options.analogClock,\n        );\n\n        return Align(\n          alignment: Alignment.center,\n          child: ListView(\n            controller: scrollCtrl,\n            padding: .only(\n              top: padding.top + Theming.offset,\n              bottom: padding.bottom + Theming.offset,\n            ),\n            children: [\n              Image.asset(\n                'assets/icons/about.png',\n                color: ColorScheme.of(context).primary,\n                width: 180,\n                height: 180,\n              ),\n              Padding(\n                padding: const .symmetric(vertical: 5),\n                child: Text(\n                  'Otraku - v.$appVersion',\n                  textAlign: .center,\n                  style: TextTheme.of(context).bodyMedium,\n                ),\n              ),\n              const Text('An unofficial AniList app', textAlign: .center),\n              const SizedBox(height: 30),\n              ListTile(\n                leading: const Icon(Ionicons.logo_discord),\n                title: const Text('Discord'),\n                onTap: () => SnackBarExtension.launch(context, 'https://discord.gg/YN2QWVbFef'),\n              ),\n              ListTile(\n                leading: const Icon(Ionicons.logo_github),\n                title: const Text('Source Code'),\n                onTap: () =>\n                    SnackBarExtension.launch(context, 'https://github.com/lotusprey/otraku'),\n              ),\n              ListTile(\n                leading: const Icon(Ionicons.cash_outline),\n                title: const Text('Donate'),\n                onTap: () => SnackBarExtension.launch(context, 'https://ko-fi.com/lotusgate'),\n              ),\n              ListTile(\n                leading: const Icon(Ionicons.finger_print),\n                title: const Text('Privacy Policy'),\n                onTap: () => SnackBarExtension.launch(\n                  context,\n                  'https://sites.google.com/view/otraku/privacy-policy',\n                ),\n              ),\n              const ListTile(\n                leading: Icon(Ionicons.trash_bin_outline),\n                title: Text('Clear Image Cache'),\n                onTap: clearImageCache,\n              ),\n              ListTile(\n                leading: Icon(Ionicons.refresh_outline),\n                title: Text('Reset Options'),\n                onTap: () => ref.read(persistenceProvider.notifier).setOptions(.empty()),\n              ),\n              if (lastJobTimestamp != null) ...[\n                Padding(\n                  padding: const .only(left: Theming.offset, right: Theming.offset, top: 20),\n                  child: Text(\n                    'Performed a notification check around $lastJobTimestamp.',\n                    style: TextTheme.of(context).labelMedium,\n                    textAlign: .center,\n                  ),\n                ),\n              ],\n            ],\n          ),\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/settings/settings_app_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/input/stateful_tiles.dart';\nimport 'package:otraku/feature/discover/discover_model.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/feature/home/home_model.dart';\nimport 'package:otraku/feature/settings/theme_preview.dart';\n\nclass SettingsAppSubview extends ConsumerWidget {\n  const SettingsAppSubview(this.scrollCtrl);\n\n  final ScrollController scrollCtrl;\n\n  @override\n  Widget build(BuildContext context, WidgetRef ref) {\n    final listPadding = MediaQuery.paddingOf(context);\n    const tilePadding = EdgeInsets.only(\n      bottom: Theming.offset,\n      left: Theming.offset,\n      right: Theming.offset,\n    );\n\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    final update = (Options options) => ref.read(persistenceProvider.notifier).setOptions(options);\n\n    return ListView(\n      controller: scrollCtrl,\n      padding: .only(\n        top: listPadding.top + Theming.offset,\n        bottom: listPadding.bottom + Theming.offset + 60,\n      ),\n      children: [\n        ExpansionTile(\n          title: const Text('Appearance'),\n          initiallyExpanded: true,\n          expandedCrossAxisAlignment: .stretch,\n          children: [\n            Padding(\n              padding: const .only(\n                bottom: Theming.offset,\n                left: Theming.offset,\n                right: Theming.offset,\n              ),\n              child: StatefulSegmentedButton(\n                segments: const [\n                  ButtonSegment(\n                    value: ThemeMode.system,\n                    label: Text('System'),\n                    icon: Icon(Icons.sync_outlined),\n                  ),\n                  ButtonSegment(\n                    value: ThemeMode.light,\n                    label: Text('Light'),\n                    icon: Icon(Icons.wb_sunny_outlined),\n                  ),\n                  ButtonSegment(\n                    value: ThemeMode.dark,\n                    label: Text('Dark'),\n                    icon: Icon(Icons.mode_night_outlined),\n                  ),\n                ],\n                value: options.themeMode,\n                onChanged: (themeMode) => update(options.copyWith(themeMode: themeMode)),\n              ),\n            ),\n            ThemePreview(ref: ref, options: options),\n            const SizedBox(height: Theming.offset / 2),\n            StatefulSwitchListTile(\n              title: const Text('High Contrast'),\n              subtitle: const Text('Pure backgrounds & outlined cards'),\n              value: options.highContrast,\n              onChanged: (v) => update(options.copyWith(highContrast: v)),\n            ),\n            const SizedBox(height: Theming.offset / 2),\n            Padding(\n              padding: const .only(\n                left: Theming.offset,\n                right: Theming.offset,\n                bottom: Theming.offset,\n              ),\n              child: const Text('Button Orientation'),\n            ),\n            Padding(\n              padding: const .only(\n                left: Theming.offset,\n                right: Theming.offset,\n                bottom: Theming.offset,\n              ),\n              child: StatefulSegmentedButton(\n                segments: const [\n                  ButtonSegment(\n                    value: ButtonOrientation.auto,\n                    label: Text('Auto'),\n                    icon: Icon(Icons.align_horizontal_center_rounded),\n                  ),\n                  ButtonSegment(\n                    value: ButtonOrientation.left,\n                    label: Text('Left'),\n                    icon: Icon(Icons.align_horizontal_left_rounded),\n                  ),\n                  ButtonSegment(\n                    value: ButtonOrientation.right,\n                    label: Text('Right'),\n                    icon: Icon(Icons.align_horizontal_right_rounded),\n                  ),\n                ],\n                value: options.buttonOrientation,\n                onChanged: (buttonOrientation) =>\n                    update(options.copyWith(buttonOrientation: buttonOrientation)),\n              ),\n            ),\n          ],\n        ),\n        ExpansionTile(\n          title: const Text('Collection Previews'),\n          children: [\n            StatefulSwitchListTile(\n              title: const Text('Anime Collection Preview'),\n              subtitle: const Text(\n                'Only load your watched/rewatched anime '\n                'and expand to full collection with the floating button',\n              ),\n              value: options.animeCollectionPreview,\n              onChanged: (v) => update(options.copyWith(animeCollectionPreview: v)),\n            ),\n            StatefulSwitchListTile(\n              title: const Text('Manga Collection Preview'),\n              subtitle: const Text(\n                'Only load your read/reread manga '\n                'and expand to full collection with the floating button',\n              ),\n              value: options.mangaCollectionPreview,\n              onChanged: (v) => update(options.copyWith(mangaCollectionPreview: v)),\n            ),\n          ],\n        ),\n        ExpansionTile(\n          title: const Text('Defaults'),\n          children: [\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Home Tab',\n                items: HomeTab.values.map((v) => (v.label, v)).toList(),\n                value: options.homeTab,\n                onChanged: (v) => update(options.copyWith(homeTab: v)),\n                highContrast: options.highContrast,\n              ),\n            ),\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Discover Type',\n                items: DiscoverType.values.map((v) => (v.label, v)).toList(),\n                value: options.discoverType,\n                onChanged: (v) => update(options.copyWith(discoverType: v)),\n                highContrast: options.highContrast,\n              ),\n            ),\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Image Quality',\n                items: ImageQuality.values.map((v) => (v.label, v)).toList(),\n                value: options.imageQuality,\n                onChanged: (v) => update(options.copyWith(imageQuality: v)),\n                highContrast: options.highContrast,\n              ),\n            ),\n          ],\n        ),\n        ExpansionTile(\n          title: const Text('View Layouts'),\n          children: [\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Discover View',\n                items: const [\n                  ('Detailed', DiscoverItemView.detailed),\n                  ('Simple', DiscoverItemView.simple),\n                ],\n                value: options.discoverItemView,\n                onChanged: (v) => update(options.copyWith(discoverItemView: v)),\n                highContrast: options.highContrast,\n              ),\n            ),\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Collection View',\n                items: const [\n                  ('Detailed', CollectionItemView.detailed),\n                  ('Simple', CollectionItemView.simple),\n                ],\n                value: options.collectionItemView,\n                onChanged: (v) => update(options.copyWith(collectionItemView: v)),\n                highContrast: options.highContrast,\n              ),\n            ),\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Collection Preview View',\n                items: const [\n                  ('Detailed', CollectionItemView.detailed),\n                  ('Simple', CollectionItemView.simple),\n                ],\n                value: options.collectionPreviewItemView,\n                onChanged: (v) => update(options.copyWith(collectionPreviewItemView: v)),\n                highContrast: options.highContrast,\n              ),\n            ),\n          ],\n        ),\n        StatefulSwitchListTile(\n          title: const Text('12 Hour Clock'),\n          value: options.analogClock,\n          onChanged: (v) => update(options.copyWith(analogClock: v)),\n        ),\n        StatefulSwitchListTile(\n          title: const Text('Confirm Exit'),\n          value: options.confirmExit,\n          onChanged: (v) => update(options.copyWith(confirmExit: v)),\n        ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/settings/settings_content_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/input/stateful_tiles.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/settings/settings_model.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass SettingsContentSubview extends StatelessWidget {\n  const SettingsContentSubview(this.scrollCtrl, this.settings, this.highContrast);\n\n  final ScrollController scrollCtrl;\n  final Settings settings;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final listPadding = MediaQuery.paddingOf(context);\n    const tilePadding = EdgeInsets.only(\n      bottom: Theming.offset,\n      left: Theming.offset,\n      right: Theming.offset,\n    );\n\n    final sheetInitialHeight = MediaQuery.sizeOf(context).height;\n\n    return ListView(\n      controller: scrollCtrl,\n      padding: .only(\n        top: listPadding.top + Theming.offset,\n        bottom: listPadding.bottom + Theming.offset,\n      ),\n      children: [\n        ExpansionTile(\n          title: const Text('Media'),\n          initiallyExpanded: true,\n          children: [\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Title Language',\n                items: TitleLanguage.values.map((v) => (v.label, v)).toList(),\n                value: settings.titleLanguage,\n                onChanged: (v) => settings.titleLanguage = v,\n                highContrast: highContrast,\n              ),\n            ),\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Character & Staff Names',\n                items: PersonNaming.values.map((v) => (v.label, v)).toList(),\n                value: settings.personNaming,\n                onChanged: (v) => settings.personNaming = v,\n                highContrast: highContrast,\n              ),\n            ),\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Activity Merge Time',\n                items: const [\n                  ('Never', 0),\n                  ('30 Minutes', 30),\n                  ('1 Hour', 60),\n                  ('2 Hours', 120),\n                  ('3 Hours', 180),\n                  ('6 Hours', 360),\n                  ('12 Hours', 720),\n                  ('1 Day', 1440),\n                  ('2 Days', 2880),\n                  ('3 Days', 4320),\n                  ('1 Week', 10080),\n                  ('2 Weeks', 20160),\n                  ('Always', 29160),\n                ],\n                value: settings.activityMergeTime,\n                onChanged: (v) => settings.activityMergeTime = v,\n                highContrast: highContrast,\n              ),\n            ),\n            StatefulSwitchListTile(\n              title: const Text('18+ Content'),\n              value: settings.displayAdultContent,\n              onChanged: (val) => settings.displayAdultContent = val,\n            ),\n            StatefulSwitchListTile(\n              title: const Text('Airing Anime Notifications'),\n              value: settings.airingNotifications,\n              onChanged: (val) => settings.airingNotifications = val,\n            ),\n          ],\n        ),\n        ExpansionTile(\n          title: const Text('Lists'),\n          initiallyExpanded: true,\n          children: [\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Scoring System',\n                items: ScoreFormat.values.map((v) => (v.label, v)).toList(),\n                value: settings.scoreFormat,\n                onChanged: (v) => settings.scoreFormat = v,\n                highContrast: highContrast,\n              ),\n            ),\n            Padding(\n              padding: tilePadding,\n              child: ChipSelector.ensureSelected(\n                title: 'Default Site List Sort',\n                items: EntrySort.rowOrders.map((v) => (v.label, v)).toList(),\n                value: settings.defaultSort,\n                onChanged: (v) => settings.defaultSort = v,\n                highContrast: highContrast,\n              ),\n            ),\n            StatefulCheckboxListTile(\n              title: const Text('Split Completed Anime'),\n              value: settings.splitCompletedAnime,\n              onChanged: (val) => settings.splitCompletedAnime = val!,\n            ),\n            ListTile(\n              title: const Text('Anime Custom Lists'),\n              leading: const Icon(Ionicons.film_outline),\n              onTap: () => showSheet(\n                context,\n                SimpleSheet(\n                  initialHeight: sheetInitialHeight,\n                  builder: (context, scrollCtrl) => _ListManagement(\n                    title: 'Anime Custom Lists',\n                    label: 'Anime custom list',\n                    items: settings.animeCustomLists,\n                    scrollCtrl: scrollCtrl,\n                  ),\n                ),\n              ),\n            ),\n            StatefulCheckboxListTile(\n              title: const Text('Split Completed Manga'),\n              value: settings.splitCompletedManga,\n              onChanged: (val) => settings.splitCompletedManga = val!,\n            ),\n            ListTile(\n              title: const Text('Manga Custom Lists'),\n              leading: const Icon(Ionicons.book_outline),\n              onTap: () => showSheet(\n                context,\n                SimpleSheet(\n                  initialHeight: sheetInitialHeight,\n                  builder: (context, scrollCtrl) => _ListManagement(\n                    title: 'Manga Custom Lists',\n                    label: 'Manga custom list',\n                    items: settings.mangaCustomLists,\n                    scrollCtrl: scrollCtrl,\n                  ),\n                ),\n              ),\n            ),\n            StatefulSwitchListTile(\n              title: const Text('Advanced Scoring'),\n              value: settings.advancedScoringEnabled,\n              onChanged: (val) => settings.advancedScoringEnabled = val,\n            ),\n            ListTile(\n              title: const Text('Advanced Score Sections'),\n              leading: const Icon(Ionicons.star_half),\n              onTap: () => showSheet(\n                context,\n                SimpleSheet(\n                  initialHeight: sheetInitialHeight,\n                  builder: (context, scrollCtrl) => _ListManagement(\n                    title: 'Advanced Score Sections',\n                    label: 'Advanced score section',\n                    items: settings.advancedScoreSections,\n                    scrollCtrl: scrollCtrl,\n                  ),\n                ),\n              ),\n            ),\n          ],\n        ),\n        ExpansionTile(\n          title: const Text('Social'),\n          initiallyExpanded: true,\n          expandedCrossAxisAlignment: .stretch,\n          children: [\n            for (final e in settings.disabledListActivity.entries)\n              StatefulCheckboxListTile(\n                title: Text('Create ${e.key.label(null)} Activities'),\n                value: !e.value,\n                onChanged: (val) => settings.disabledListActivity[e.key] = !val!,\n              ),\n            StatefulSwitchListTile(\n              title: const Text('Limit Messages'),\n              subtitle: const Text('Only users I follow can message me'),\n              value: settings.restrictMessagesToFollowing,\n              onChanged: (val) => settings.restrictMessagesToFollowing = val,\n            ),\n          ],\n        ),\n      ],\n    );\n  }\n}\n\nclass _ListManagement extends StatefulWidget {\n  const _ListManagement({\n    required this.title,\n    required this.label,\n    required this.items,\n    required this.scrollCtrl,\n  });\n\n  final String title;\n  final String label;\n  final List<String> items;\n  final ScrollController scrollCtrl;\n\n  @override\n  State<_ListManagement> createState() => _ListManagementState();\n}\n\nclass _ListManagementState extends State<_ListManagement> {\n  @override\n  Widget build(BuildContext context) {\n    final items = widget.items;\n\n    return Column(\n      mainAxisSize: .min,\n      crossAxisAlignment: .stretch,\n      children: [\n        Padding(\n          padding: const .only(top: Theming.offset),\n          child: Row(\n            children: [\n              Padding(\n                padding: const .symmetric(horizontal: Theming.offset),\n                child: Text(widget.title, style: TextTheme.of(context).bodyMedium),\n              ),\n              const Spacer(),\n              IconButton(\n                tooltip: 'Add',\n                icon: const Icon(Icons.add_rounded),\n                onPressed: () async {\n                  final newItem = await showDialog<String?>(\n                    context: context,\n                    builder: (context) => TextInputDialog(\n                      title: widget.label,\n                      initialValue: '',\n                      validator: (val) => items.contains(val) ? 'Already exists.' : null,\n                    ),\n                  );\n\n                  if (newItem != null) {\n                    setState(() => items.add(newItem));\n                  }\n                },\n              ),\n            ],\n          ),\n        ),\n        ListView.builder(\n          shrinkWrap: true,\n          controller: widget.scrollCtrl,\n          itemCount: items.length,\n          itemBuilder: (context, i) => ListTile(\n            key: Key(items[i]),\n            title: Text(items[i]),\n            trailing: Row(\n              mainAxisSize: .min,\n              children: [\n                IconButton(\n                  tooltip: 'Remove',\n                  icon: const Icon(Icons.delete_rounded),\n                  onPressed: () => setState(() => items.removeAt(i)),\n                ),\n                IconButton(\n                  tooltip: 'Rename',\n                  icon: const Icon(Icons.edit_rounded),\n                  onPressed: () async {\n                    final renamedItem = await showDialog<String?>(\n                      context: context,\n                      builder: (context) => TextInputDialog(\n                        title: widget.label,\n                        initialValue: items[i],\n                        validator: (val) =>\n                            items.contains(val) && val != items[i] ? 'Already exists.' : null,\n                      ),\n                    );\n\n                    if (renamedItem != null) {\n                      setState(() => items[i] = renamedItem);\n                    }\n                  },\n                ),\n              ],\n            ),\n          ),\n        ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/settings/settings_model.dart",
    "content": "import 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/notification/notifications_model.dart';\n\n/// Some fields are modifiable to allow for quick and simple edits.\n/// But to apply those edits, the [SettingsNotifier] should be used.\nclass Settings {\n  Settings._({\n    required this.unreadNotifications,\n    required this.scoreFormat,\n    required this.defaultSort,\n    required this.titleLanguage,\n    required this.personNaming,\n    required this.activityMergeTime,\n    required this.splitCompletedAnime,\n    required this.splitCompletedManga,\n    required this.displayAdultContent,\n    required this.airingNotifications,\n    required this.advancedScoringEnabled,\n    required this.restrictMessagesToFollowing,\n    required this.advancedScoreSections,\n    required this.animeCustomLists,\n    required this.mangaCustomLists,\n    required this.disabledListActivity,\n    required this.notificationOptions,\n  });\n\n  factory Settings(Map<String, dynamic> map) => Settings._(\n    unreadNotifications: map['unreadNotificationCount'] ?? 0,\n    scoreFormat: ScoreFormat.from(map['mediaListOptions']?['scoreFormat']),\n    defaultSort: EntrySort.fromRowOrder(map['mediaListOptions']?['rowOrder']),\n    titleLanguage: TitleLanguage.from(map['options']?['titleLanguage']),\n    personNaming: PersonNaming.from(map['options']?['staffNameLanguage']),\n    activityMergeTime: map['options']?['activityMergeTime'] ?? 720,\n    splitCompletedAnime:\n        map['mediaListOptions']?['animeList']?['splitCompletedSectionByFormat'] ?? false,\n    splitCompletedManga:\n        map['mediaListOptions']?['mangaList']?['splitCompletedSectionByFormat'] ?? false,\n    displayAdultContent: map['options']?['displayAdultContent'] ?? false,\n    airingNotifications: map['options']?['airingNotifications'] ?? true,\n    advancedScoringEnabled:\n        map['mediaListOptions']?['animeList']?['advancedScoringEnabled'] ?? false,\n    restrictMessagesToFollowing: map['options']?['restrictMessagesToFollowing'] ?? false,\n    advancedScoreSections: List<String>.from(\n      map['mediaListOptions']?['animeList']?['advancedScoring'] ?? const <String>[],\n    ),\n    animeCustomLists: List<String>.from(\n      map['mediaListOptions']?['animeList']?['customLists'] ?? const <String>[],\n    ),\n    mangaCustomLists: List<String>.from(\n      map['mediaListOptions']?['mangaList']?['customLists'] ?? const <String>[],\n    ),\n    disabledListActivity: {\n      for (var activity in map['options']?['disabledListActivity'] ?? const [])\n        ?ListStatus.from(activity['type']): activity['disabled'],\n    },\n    notificationOptions: {\n      for (var option in map['options']?['notificationOptions'] ?? const [])\n        ?NotificationType.from(option['type']): option['enabled'],\n    },\n  );\n\n  factory Settings.empty() => Settings._(\n    unreadNotifications: 0,\n    scoreFormat: .point10,\n    defaultSort: .title,\n    titleLanguage: .romaji,\n    personNaming: .romajiWestern,\n    activityMergeTime: 720,\n    splitCompletedAnime: false,\n    splitCompletedManga: false,\n    displayAdultContent: false,\n    airingNotifications: true,\n    advancedScoringEnabled: false,\n    restrictMessagesToFollowing: false,\n    advancedScoreSections: const [],\n    animeCustomLists: const [],\n    mangaCustomLists: const [],\n    disabledListActivity: const {},\n    notificationOptions: const {},\n  );\n\n  ScoreFormat scoreFormat;\n  EntrySort defaultSort;\n  TitleLanguage titleLanguage;\n  PersonNaming personNaming;\n  int activityMergeTime;\n  bool splitCompletedAnime;\n  bool splitCompletedManga;\n  bool displayAdultContent;\n  bool airingNotifications;\n  bool advancedScoringEnabled;\n  bool restrictMessagesToFollowing;\n  final int unreadNotifications;\n  final List<String> advancedScoreSections;\n  final List<String> animeCustomLists;\n  final List<String> mangaCustomLists;\n  final Map<ListStatus, bool> disabledListActivity;\n  final Map<NotificationType, bool> notificationOptions;\n\n  Settings copy({int unreadNotifications = 0}) => Settings._(\n    unreadNotifications: unreadNotifications,\n    scoreFormat: scoreFormat,\n    defaultSort: defaultSort,\n    titleLanguage: titleLanguage,\n    personNaming: personNaming,\n    activityMergeTime: activityMergeTime,\n    splitCompletedAnime: splitCompletedAnime,\n    splitCompletedManga: splitCompletedManga,\n    displayAdultContent: displayAdultContent,\n    airingNotifications: airingNotifications,\n    advancedScoringEnabled: advancedScoringEnabled,\n    restrictMessagesToFollowing: restrictMessagesToFollowing,\n    advancedScoreSections: [...advancedScoreSections],\n    animeCustomLists: [...animeCustomLists],\n    mangaCustomLists: [...mangaCustomLists],\n    disabledListActivity: {...disabledListActivity},\n    notificationOptions: {...notificationOptions},\n  );\n\n  Map<String, dynamic> toGraphQlVariables() => {\n    'titleLanguage': titleLanguage.value,\n    'staffNameLanguage': personNaming.value,\n    'activityMergeTime': activityMergeTime,\n    'displayAdultContent': displayAdultContent,\n    'scoreFormat': scoreFormat.value,\n    'rowOrder': defaultSort.toRowOrder(),\n    'advancedScoring': advancedScoreSections,\n    'advancedScoringEnabled': advancedScoringEnabled,\n    'animeCustomLists': animeCustomLists,\n    'mangaCustomLists': mangaCustomLists,\n    'splitCompletedAnime': splitCompletedAnime,\n    'splitCompletedManga': splitCompletedManga,\n    'restrictMessagesToFollowing': restrictMessagesToFollowing,\n    'airingNotifications': airingNotifications,\n    'disabledListActivity': disabledListActivity.entries\n        .map((e) => {'type': e.key.value, 'disabled': e.value})\n        .toList(),\n    'notificationOptions': notificationOptions.entries\n        .map((e) => {'type': e.key.value, 'enabled': e.value})\n        .toList(),\n  };\n}\n\nenum TitleLanguage {\n  romaji('Romaji', 'ROMAJI'),\n  english('English', 'ENGLISH'),\n  native('Native', 'NATIVE');\n\n  const TitleLanguage(this.label, this.value);\n\n  final String label;\n  final String value;\n\n  static TitleLanguage from(String? value) =>\n      TitleLanguage.values.firstWhere((v) => v.value == value, orElse: () => romaji);\n}\n\nenum PersonNaming {\n  romajiWestern('Romaji, Western Order', 'ROMAJI_WESTERN'),\n  romaji('Romaji', 'ROMAJI'),\n  native('Native', 'NATIVE');\n\n  const PersonNaming(this.label, this.value);\n\n  final String label;\n  final String value;\n\n  static PersonNaming from(String? value) =>\n      PersonNaming.values.firstWhere((v) => v.value == value, orElse: () => romajiWestern);\n}\n"
  },
  {
    "path": "lib/feature/settings/settings_notifications_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/input/stateful_tiles.dart';\nimport 'package:otraku/feature/settings/settings_model.dart';\n\nclass SettingsNotificationsSubview extends StatelessWidget {\n  const SettingsNotificationsSubview(this.scrollCtrl, this.settings);\n\n  final ScrollController scrollCtrl;\n  final Settings settings;\n\n  @override\n  Widget build(BuildContext context) {\n    final listPadding = MediaQuery.paddingOf(context);\n\n    return ListView.builder(\n      controller: scrollCtrl,\n      padding: .only(\n        top: listPadding.top + Theming.offset,\n        bottom: listPadding.bottom + Theming.offset,\n      ),\n      itemCount: settings.notificationOptions.length,\n      itemBuilder: (context, i) {\n        final e = settings.notificationOptions.entries.elementAt(i);\n\n        return StatefulCheckboxListTile(\n          title: Text(e.key.label),\n          value: e.value,\n          onChanged: (v) => settings.notificationOptions[e.key] = v!,\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/settings/settings_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter/foundation.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\nimport 'package:otraku/feature/collection/collection_provider.dart';\nimport 'package:otraku/feature/settings/settings_model.dart';\n\nfinal settingsProvider = AsyncNotifierProvider.autoDispose<SettingsNotifier, Settings>(\n  SettingsNotifier.new,\n);\n\nclass SettingsNotifier extends AsyncNotifier<Settings> {\n  @override\n  FutureOr<Settings> build() async {\n    final viewerId = ref.watch(viewerIdProvider);\n    if (viewerId == null) return .empty();\n\n    final data = await ref.read(repositoryProvider).request(GqlQuery.settings);\n    return Settings(data['Viewer']);\n  }\n\n  /// Update settings and if necessary\n  /// restart collections to reflect the changes.\n  Future<void> updateSettings(Settings other) async {\n    final viewerId = ref.watch(viewerIdProvider);\n    if (viewerId == null) return;\n\n    final prev = state.value;\n    state = await AsyncValue.guard(() async {\n      final data = await ref\n          .read(repositoryProvider)\n          .request(GqlMutation.updateSettings, other.toGraphQlVariables());\n\n      return Settings(data['UpdateUser']);\n    });\n\n    final next = state.value;\n    if (prev == null || next == null) return;\n\n    var invalidateAnimeCollection = false;\n    var invalidateMangaCollection = false;\n\n    if (prev.scoreFormat != next.scoreFormat || prev.titleLanguage != next.titleLanguage) {\n      invalidateAnimeCollection = true;\n      invalidateMangaCollection = true;\n    } else {\n      if (prev.splitCompletedAnime != next.splitCompletedAnime ||\n          !listEquals(prev.animeCustomLists, next.animeCustomLists)) {\n        invalidateAnimeCollection = true;\n      }\n\n      if (prev.splitCompletedManga != next.splitCompletedManga ||\n          !listEquals(prev.mangaCustomLists, next.mangaCustomLists)) {\n        invalidateMangaCollection = true;\n      }\n    }\n\n    if (invalidateAnimeCollection) {\n      ref.invalidate(collectionProvider((userId: viewerId, ofAnime: true)));\n    }\n\n    if (invalidateMangaCollection) {\n      ref.invalidate(collectionProvider((userId: viewerId, ofAnime: false)));\n    }\n  }\n\n  Future<void> refetchUnread() async {\n    try {\n      final data = await ref.read(repositoryProvider).request(GqlQuery.settings, {\n        'withData': false,\n      });\n      state = state.whenData(\n        (v) => v.copy(unreadNotifications: data['Viewer']['unreadNotificationCount'] ?? 0),\n      );\n    } catch (_) {}\n  }\n\n  void clearUnread() => state = state.whenData((v) => v.copy(unreadNotifications: 0));\n}\n"
  },
  {
    "path": "lib/feature/settings/settings_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/scroll_controller_extension.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/settings/settings_model.dart';\nimport 'package:otraku/feature/settings/settings_provider.dart';\nimport 'package:otraku/feature/settings/settings_app_view.dart';\nimport 'package:otraku/feature/settings/settings_content_view.dart';\nimport 'package:otraku/feature/settings/settings_notifications_view.dart';\nimport 'package:otraku/feature/settings/settings_about_view.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/loaders.dart';\n\nclass SettingsView extends ConsumerStatefulWidget {\n  const SettingsView();\n\n  @override\n  ConsumerState<SettingsView> createState() => _SettingsViewState();\n}\n\nclass _SettingsViewState extends ConsumerState<SettingsView> with SingleTickerProviderStateMixin {\n  late final _tabCtrl = TabController(length: 4, vsync: this);\n  final _scrollCtrl = ScrollController();\n  AsyncValue<Settings>? _settings;\n\n  @override\n  void initState() {\n    super.initState();\n    _tabCtrl.addListener(() => setState(() {}));\n  }\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final viewerId = ref.watch(viewerIdProvider);\n    if (viewerId == null) {\n      _settings = null;\n    } else {\n      _settings ??= ref.watch(settingsProvider).whenData((data) => data.copy());\n\n      ref.listen(\n        settingsProvider,\n        (_, s) => s.whenOrNull(\n          loading: () => _settings = const AsyncValue.loading(),\n          data: (data) => _settings = AsyncValue.data(data.copy()),\n          error: (error, _) => SnackBarExtension.show(context, error.toString()),\n        ),\n      );\n    }\n\n    final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast));\n\n    final tabs = [\n      ConstrainedView(padded: false, child: SettingsAppSubview(_scrollCtrl)),\n      switch (_settings) {\n        null => const Center(\n          child: Padding(\n            padding: Theming.paddingAll,\n            child: Text('Log in to view content settings'),\n          ),\n        ),\n        AsyncData(:final value) => SettingsContentSubview(_scrollCtrl, value, highContrast),\n        AsyncError(:final error) => Center(\n          child: Padding(\n            padding: Theming.paddingAll,\n            child: Text('Failed to load: ${error.toString()}'),\n          ),\n        ),\n        AsyncLoading() => const Center(child: Loader()),\n      },\n      switch (_settings) {\n        null => const Center(\n          child: Padding(\n            padding: Theming.paddingAll,\n            child: Text('Log in to view notification settings'),\n          ),\n        ),\n        AsyncData(:final value) => SettingsNotificationsSubview(_scrollCtrl, value),\n        AsyncError(:final error) => Center(\n          child: Padding(\n            padding: Theming.paddingAll,\n            child: Text('Failed to load: ${error.toString()}'),\n          ),\n        ),\n        AsyncLoading() => const Center(child: Loader()),\n      },\n      ConstrainedView(padded: false, child: SettingsAboutSubview(_scrollCtrl)),\n    ];\n\n    final floatingAction = switch (_settings) {\n      AsyncData(:final value) => HidingFloatingActionButton(\n        key: const Key('save'),\n        scrollCtrl: _scrollCtrl,\n        child: _SaveButton(() => ref.read(settingsProvider.notifier).updateSettings(value)),\n      ),\n      _ => null,\n    };\n\n    return AdaptiveScaffold(\n      topBar: TopBarAnimatedSwitcher(switch (_tabCtrl.index) {\n        0 => const TopBar(key: Key('0'), title: 'App'),\n        1 => const TopBar(key: Key('1'), title: 'Content'),\n        2 => const TopBar(key: Key('2'), title: 'Notifications'),\n        _ => const TopBar(key: Key('3'), title: 'About'),\n      }),\n      floatingAction: floatingAction,\n      navigationConfig: NavigationConfig(\n        selected: _tabCtrl.index,\n        onSame: (_) => _scrollCtrl.scrollToTop(),\n        onChanged: (i) => _tabCtrl.index = i,\n        items: const {\n          'App': Ionicons.color_palette_outline,\n          'Content': Ionicons.tv_outline,\n          'Notifications': Ionicons.notifications_outline,\n          'About': Ionicons.information_outline,\n        },\n      ),\n      child: TabBarView(controller: _tabCtrl, children: tabs),\n    );\n  }\n}\n\nclass _SaveButton extends StatefulWidget {\n  const _SaveButton(this.onTap) : super(key: const Key('saveSettings'));\n\n  final Future<void> Function() onTap;\n\n  @override\n  State<_SaveButton> createState() => __SaveButtonState();\n}\n\nclass __SaveButtonState extends State<_SaveButton> {\n  var _hidden = false;\n\n  @override\n  Widget build(BuildContext context) {\n    return FloatingActionButton(\n      tooltip: 'Save Settings',\n      onPressed: _hidden\n          ? null\n          : () async {\n              setState(() => _hidden = true);\n              await widget.onTap();\n              setState(() => _hidden = false);\n            },\n      child: _hidden ? const Icon(Ionicons.time_outline) : const Icon(Ionicons.save_outline),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/settings/theme_preview.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/widget/shadowed_overflow_list.dart';\nimport 'package:otraku/util/theming.dart';\n\nconst _previewHeight = 170.0;\n\nclass ThemePreview extends StatelessWidget {\n  const ThemePreview({required this.ref, required this.options});\n\n  final WidgetRef ref;\n  final Options options;\n\n  @override\n  Widget build(BuildContext context) {\n    final brightness = ColorScheme.of(context).brightness;\n\n    final systemPrimaryColor = ref.watch(\n      persistenceProvider.select(\n        (s) => brightness == Brightness.dark\n            ? s.systemColors.darkPrimaryColor\n            : s.systemColors.lightPrimaryColor,\n      ),\n    );\n\n    final background = options.highContrast\n        ? brightness == Brightness.dark\n              ? Colors.black\n              : Colors.white\n        : null;\n\n    final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n\n    final children = <_ThemeCard>[];\n    if (systemPrimaryColor != null) {\n      children.add(\n        _ThemeCard(\n          name: 'System',\n          scheme: ColorScheme.fromSeed(\n            seedColor: systemPrimaryColor,\n            brightness: brightness,\n          ).copyWith(surface: background),\n          active: options.themeBase == null,\n          onTap: () => ref\n              .read(persistenceProvider.notifier)\n              .setOptions(options.copyWith(themeBase: (null,))),\n        ),\n      );\n    }\n\n    for (final tb in ThemeBase.values) {\n      children.add(\n        _ThemeCard(\n          name: tb.title,\n          scheme: ColorScheme.fromSeed(\n            seedColor: tb.seed,\n            brightness: brightness,\n          ).copyWith(surface: background),\n          active: options.themeBase == tb,\n          onTap: () =>\n              ref.read(persistenceProvider.notifier).setOptions(options.copyWith(themeBase: (tb,))),\n        ),\n      );\n    }\n\n    return SizedBox(\n      height: _previewHeight + bodyMediumLineHeight + 5,\n      child: ShadowedOverflowList(\n        itemCount: children.length,\n        itemExtent: 125,\n        itemBuilder: (_, i) => children[i],\n      ),\n    );\n  }\n}\n\nclass _ThemeCard extends StatelessWidget {\n  const _ThemeCard({\n    required this.name,\n    required this.active,\n    required this.scheme,\n    required this.onTap,\n  });\n\n  final String name;\n  final bool active;\n  final ColorScheme scheme;\n  final void Function() onTap;\n\n  @override\n  Widget build(BuildContext context) {\n    final borderWidth = active ? 3.0 : 1.0;\n    final borderColor = active ? scheme.primary : scheme.surfaceContainerHighest;\n\n    return GestureDetector(\n      onTap: onTap,\n      child: Column(\n        spacing: 5,\n        children: [\n          Container(\n            height: _previewHeight,\n            padding: const .all(5),\n            decoration: BoxDecoration(\n              color: scheme.surface,\n              border: .all(color: borderColor, width: borderWidth),\n              borderRadius: Theming.borderRadiusSmall,\n            ),\n            child: Column(\n              children: [\n                Column(\n                  crossAxisAlignment: .start,\n                  children: [\n                    Container(\n                      height: Theming.offset,\n                      width: 60,\n                      decoration: BoxDecoration(\n                        color: scheme.onSurface,\n                        borderRadius: Theming.borderRadiusBig,\n                      ),\n                    ),\n                    const SizedBox(height: Theming.offset),\n                    Container(\n                      height: 40,\n                      padding: const .all(5),\n                      decoration: BoxDecoration(\n                        color: scheme.surfaceContainerHighest,\n                        borderRadius: Theming.borderRadiusSmall,\n                      ),\n                      child: Column(\n                        crossAxisAlignment: .start,\n                        children: [\n                          Container(\n                            height: 8,\n                            width: 40,\n                            decoration: BoxDecoration(\n                              color: scheme.surfaceContainerHighest,\n                              borderRadius: Theming.borderRadiusBig,\n                            ),\n                          ),\n                          const SizedBox(height: 5),\n                          Container(\n                            height: 6,\n                            width: 110,\n                            decoration: BoxDecoration(\n                              color: scheme.onSurfaceVariant,\n                              borderRadius: Theming.borderRadiusBig,\n                            ),\n                          ),\n                        ],\n                      ),\n                    ),\n                  ],\n                ),\n                const Spacer(),\n                Row(\n                  mainAxisAlignment: .end,\n                  children: [\n                    Container(\n                      width: 16,\n                      height: 16,\n                      margin: const .only(right: 7, bottom: 7),\n                      decoration: BoxDecoration(shape: .circle, color: scheme.primary),\n                      child: Center(\n                        child: Container(\n                          width: 6,\n                          height: 2,\n                          decoration: BoxDecoration(\n                            shape: .rectangle,\n                            borderRadius: Theming.borderRadiusSmall,\n                            color: scheme.onPrimary,\n                          ),\n                        ),\n                      ),\n                    ),\n                  ],\n                ),\n                SizedBox(\n                  height: 15,\n                  child: Row(\n                    mainAxisAlignment: .spaceEvenly,\n                    children: [\n                      Container(\n                        height: 8,\n                        width: 8,\n                        decoration: BoxDecoration(color: scheme.primary, shape: .rectangle),\n                      ),\n                      Container(\n                        height: 8,\n                        width: 8,\n                        decoration: BoxDecoration(\n                          color: scheme.surfaceContainerHighest,\n                          shape: .circle,\n                        ),\n                      ),\n                      Container(\n                        height: 8,\n                        width: 8,\n                        decoration: BoxDecoration(\n                          color: scheme.surfaceContainerHighest,\n                          shape: .circle,\n                        ),\n                      ),\n                    ],\n                  ),\n                ),\n              ],\n            ),\n          ),\n          Text(name, overflow: .ellipsis, maxLines: 1),\n        ],\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/social/social_model.dart",
    "content": "import 'package:otraku/feature/comment/comment_model.dart';\nimport 'package:otraku/feature/forum/forum_model.dart';\nimport 'package:otraku/feature/user/user_item_model.dart';\nimport 'package:otraku/util/paged.dart';\n\nclass Social {\n  const Social({\n    this.following = const PagedWithTotal(),\n    this.followers = const PagedWithTotal(),\n    this.threads = const PagedWithTotal(),\n    this.comments = const PagedWithTotal(),\n  });\n\n  final PagedWithTotal<UserItem> following;\n  final PagedWithTotal<UserItem> followers;\n  final PagedWithTotal<ThreadItem> threads;\n  final PagedWithTotal<Comment> comments;\n\n  int getCount(SocialTab tab) => switch (tab) {\n    .following => following.total,\n    .followers => followers.total,\n    .threads => threads.total,\n    .comments => comments.total,\n  };\n}\n\nenum SocialTab {\n  following,\n  followers,\n  threads,\n  comments;\n\n  String get title => switch (this) {\n    .following => 'Following',\n    .followers => 'Followers',\n    .threads => 'Threads',\n    .comments => 'Comments',\n  };\n}\n"
  },
  {
    "path": "lib/feature/social/social_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/comment/comment_model.dart';\nimport 'package:otraku/feature/forum/forum_model.dart';\nimport 'package:otraku/feature/social/social_model.dart';\nimport 'package:otraku/feature/user/user_item_model.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal socialProvider = AsyncNotifierProvider.autoDispose.family<SocialNotifier, Social, int>(\n  SocialNotifier.new,\n);\n\nclass SocialNotifier extends AsyncNotifier<Social> {\n  SocialNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Social> build() => _fetch(const Social(), null);\n\n  Future<void> fetch(SocialTab tab) async {\n    final oldState = state.value ?? const Social();\n    switch (tab) {\n      case .following:\n        if (!oldState.following.hasNext) return;\n      case .followers:\n        if (!oldState.followers.hasNext) return;\n      case .threads:\n        if (!oldState.threads.hasNext) return;\n      case .comments:\n        if (!oldState.comments.hasNext) return;\n    }\n    state = await AsyncValue.guard(() => _fetch(oldState, tab));\n  }\n\n  Future<Social> _fetch(Social oldState, SocialTab? tab) async {\n    final variables = <String, dynamic>{'userId': arg};\n\n    switch (tab) {\n      case null:\n        variables['withFollowing'] = true;\n        variables['withFollowers'] = true;\n        variables['withThreads'] = true;\n        variables['withComments'] = true;\n        break;\n      case .following:\n        variables['withFollowing'] = true;\n        variables['page'] = oldState.following.next;\n        break;\n      case .followers:\n        variables['withFollowers'] = true;\n        variables['page'] = oldState.followers.next;\n        break;\n      case .threads:\n        variables['withThreads'] = true;\n        variables['page'] = oldState.threads.next;\n        break;\n      case .comments:\n        variables['withComments'] = true;\n        variables['page'] = oldState.comments.next;\n        break;\n    }\n\n    final data = await ref.read(repositoryProvider).request(GqlQuery.social, variables);\n\n    var following = oldState.following;\n    var followers = oldState.followers;\n    var threads = oldState.threads;\n    var comments = oldState.comments;\n\n    if (tab == null || tab == .following) {\n      final map = data['following'];\n      final items = <UserItem>[];\n      for (final u in map['following']) {\n        items.add(UserItem(u));\n      }\n\n      following = following.withNext(\n        items,\n        map['pageInfo']['hasNextPage'] ?? false,\n        map['pageInfo']['total'],\n      );\n    }\n\n    if (tab == null || tab == .followers) {\n      final map = data['followers'];\n      final items = <UserItem>[];\n      for (final u in map['followers']) {\n        items.add(UserItem(u));\n      }\n\n      followers = followers.withNext(\n        items,\n        map['pageInfo']['hasNextPage'] ?? false,\n        map['pageInfo']['total'],\n      );\n    }\n\n    if (tab == null || tab == .threads) {\n      final map = data['threads'];\n      final items = <ThreadItem>[];\n      for (final u in map['threads']) {\n        items.add(ThreadItem(u));\n      }\n\n      threads = threads.withNext(\n        items,\n        map['pageInfo']['hasNextPage'] ?? false,\n        map['pageInfo']['total'],\n      );\n    }\n\n    if (tab == null || tab == .comments) {\n      final map = data['comments'];\n      final items = <Comment>[];\n      for (final u in map['threadComments']) {\n        items.add(Comment(u));\n      }\n\n      comments = comments.withNext(\n        items,\n        map['pageInfo']['hasNextPage'] ?? false,\n        map['pageInfo']['total'],\n      );\n    }\n\n    return Social(following: following, followers: followers, threads: threads, comments: comments);\n  }\n}\n"
  },
  {
    "path": "lib/feature/social/social_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/scroll_controller_extension.dart';\nimport 'package:otraku/feature/comment/comment_model.dart';\nimport 'package:otraku/feature/comment/comment_tile.dart';\nimport 'package:otraku/feature/forum/thread_item_list.dart';\nimport 'package:otraku/feature/social/social_model.dart';\nimport 'package:otraku/feature/social/social_provider.dart';\nimport 'package:otraku/feature/user/user_item_grid.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/paged_view.dart';\n\nclass SocialView extends ConsumerStatefulWidget {\n  const SocialView(this.id);\n\n  final int id;\n\n  @override\n  ConsumerState<SocialView> createState() => _SocialViewState();\n}\n\nclass _SocialViewState extends ConsumerState<SocialView> with SingleTickerProviderStateMixin {\n  late final _tabCtrl = TabController(length: SocialTab.values.length, vsync: this);\n  late final _scrollCtrl = PagedController(\n    loadMore: () =>\n        ref.read(socialProvider(widget.id).notifier).fetch(SocialTab.values[_tabCtrl.index]),\n  );\n\n  @override\n  void initState() {\n    super.initState();\n    _tabCtrl.addListener(() => setState(() {}));\n  }\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final tab = SocialTab.values[_tabCtrl.index];\n\n    final viewerId = ref.watch(viewerIdProvider);\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    final count = ref.watch(socialProvider(widget.id).select((s) => s.value?.getCount(tab) ?? 0));\n\n    final onRefresh = (invalidate) => invalidate(socialProvider(widget.id));\n\n    return AdaptiveScaffold(\n      topBar: TopBarAnimatedSwitcher(\n        TopBar(\n          key: Key('${tab.title}TopBar'),\n          title: tab.title,\n          trailing: [\n            if (count > 0)\n              Padding(\n                padding: const .only(right: Theming.offset),\n                child: Text(count.toString(), style: TextTheme.of(context).titleSmall),\n              ),\n          ],\n        ),\n      ),\n      navigationConfig: NavigationConfig(\n        selected: _tabCtrl.index,\n        onChanged: (i) => _tabCtrl.index = i,\n        onSame: (_) => _scrollCtrl.scrollToTop(),\n        items: {\n          SocialTab.following.title: Ionicons.people_circle,\n          SocialTab.followers.title: Ionicons.person_circle,\n          SocialTab.threads.title: Ionicons.chatbubble_outline,\n          SocialTab.comments.title: Ionicons.chatbubbles_outline,\n        },\n      ),\n      child: TabBarView(\n        controller: _tabCtrl,\n        children: [\n          PagedView(\n            scrollCtrl: _scrollCtrl,\n            onRefresh: onRefresh,\n            provider: socialProvider(\n              widget.id,\n            ).select((s) => s.unwrapPrevious().whenData((data) => data.following)),\n            onData: (data) => UserItemGrid(data.items, highContrast: options.highContrast),\n          ),\n          PagedView(\n            scrollCtrl: _scrollCtrl,\n            onRefresh: onRefresh,\n            provider: socialProvider(\n              widget.id,\n            ).select((s) => s.unwrapPrevious().whenData((data) => data.followers)),\n            onData: (data) => UserItemGrid(data.items, highContrast: options.highContrast),\n          ),\n          PagedView(\n            scrollCtrl: _scrollCtrl,\n            onRefresh: onRefresh,\n            provider: socialProvider(\n              widget.id,\n            ).select((s) => s.unwrapPrevious().whenData((data) => data.threads)),\n            onData: (data) => ThreadItemList(data.items, options.highContrast, options.analogClock),\n          ),\n          PagedView(\n            scrollCtrl: _scrollCtrl,\n            onRefresh: onRefresh,\n            provider: socialProvider(\n              widget.id,\n            ).select((s) => s.unwrapPrevious().whenData((data) => data.comments)),\n            onData: (data) =>\n                _CommentItemList(data.items, viewerId, options.highContrast, options.analogClock),\n          ),\n        ],\n      ),\n    );\n  }\n}\n\nclass _CommentItemList extends StatelessWidget {\n  const _CommentItemList(this.items, this.viewerId, this.highContrast, this.analogClock);\n\n  final List<Comment> items;\n  final int? viewerId;\n  final bool highContrast;\n  final bool analogClock;\n\n  @override\n  Widget build(BuildContext context) {\n    return SliverList.builder(\n      itemCount: items.length,\n      itemBuilder: (context, i) {\n        final item = items[i];\n\n        final openThread = () => context.push(Routes.thread(item.threadId));\n\n        return Padding(\n          padding: const .only(bottom: Theming.offset),\n          child: Column(\n            crossAxisAlignment: .start,\n            spacing: Theming.offset,\n            children: [\n              Semantics(\n                onTap: openThread,\n                onTapHint: 'open thread',\n                child: GestureDetector(\n                  onTap: openThread,\n                  behavior: .opaque,\n                  child: Text(item.threadTitle, style: TextTheme.of(context).bodyMedium),\n                ),\n              ),\n              CommentTile(\n                item,\n                viewerId: viewerId,\n                highContrast: highContrast,\n                analogClock: analogClock,\n              ),\n            ],\n          ),\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_characters_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/staff/staff_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/widget/grid/dual_relation_grid.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/feature/staff/staff_provider.dart';\n\nclass StaffCharactersSubview extends StatelessWidget {\n  const StaffCharactersSubview({\n    required this.id,\n    required this.scrollCtrl,\n    required this.highContrast,\n  });\n\n  final int id;\n  final ScrollController scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView<(StaffRelatedItem, StaffRelatedItem)>(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(staffRelationsProvider(id)),\n      provider: staffRelationsProvider(\n        id,\n      ).select((s) => s.unwrapPrevious().whenData((data) => data.charactersAndMedia)),\n      onData: (data) => DualRelationGrid(\n        items: data.items,\n        onTapPrimary: (item) => context.push(Routes.character(item.tileId, item.tileImageUrl)),\n        onTapSecondary: (item) => context.push(Routes.media(item.tileId, item.tileImageUrl)),\n        highContrast: highContrast,\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_filter_model.dart",
    "content": "import 'package:otraku/feature/media/media_models.dart';\n\nclass StaffFilter {\n  const StaffFilter({this.sort = .startDateDesc, this.ofAnime, this.inLists});\n\n  final MediaSort sort;\n  final bool? ofAnime;\n  final bool? inLists;\n\n  StaffFilter copyWith({MediaSort? sort, (bool?,)? ofAnime, (bool?,)? inLists}) => StaffFilter(\n    sort: sort ?? this.sort,\n    ofAnime: ofAnime == null ? this.ofAnime : ofAnime.$1,\n    inLists: inLists == null ? this.inLists : inLists.$1,\n  );\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_filter_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/staff/staff_filter_model.dart';\n\nfinal staffFilterProvider = NotifierProvider.autoDispose\n    .family<StaffFilterNotifier, StaffFilter, int>(StaffFilterNotifier.new);\n\nclass StaffFilterNotifier extends Notifier<StaffFilter> {\n  StaffFilterNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  StaffFilter build() => const StaffFilter();\n\n  @override\n  set state(StaffFilter newState) => super.state = newState;\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_floating_actions.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/staff/staff_filter_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass StaffFilterButton extends StatelessWidget {\n  const StaffFilterButton(this.id, this.ref) : super(key: const Key('filterStaff'));\n\n  final int id;\n  final WidgetRef ref;\n\n  @override\n  Widget build(BuildContext context) {\n    return FloatingActionButton(\n      tooltip: 'Filter',\n      heroTag: 'filter',\n      child: const Icon(Ionicons.funnel_outline),\n      onPressed: () {\n        var filter = ref.read(staffFilterProvider(id));\n        final onDone = (_) => ref.read(staffFilterProvider(id).notifier).state = filter;\n        final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast));\n\n        showSheet(\n          context,\n          SimpleSheet(\n            initialHeight: Theming.normalTapTarget * 4 + MediaQuery.paddingOf(context).bottom + 40,\n            builder: (context, scrollCtrl) => ListView(\n              controller: scrollCtrl,\n              physics: Theming.bouncyPhysics,\n              padding: const .symmetric(horizontal: Theming.offset, vertical: 20),\n              children: [\n                ChipSelector.ensureSelected(\n                  title: 'Sort',\n                  items: MediaSort.values.map((v) => (v.label, v)).toList(),\n                  value: filter.sort,\n                  onChanged: (v) => filter = filter.copyWith(sort: v),\n                  highContrast: highContrast,\n                ),\n                ChipSelector(\n                  title: 'Type',\n                  items: const [('Anime', true), ('Manga', false)],\n                  value: filter.ofAnime,\n                  onChanged: (v) => filter = filter.copyWith(ofAnime: (v,)),\n                  highContrast: highContrast,\n                ),\n                const SizedBox(height: Theming.offset),\n                ChipSelector(\n                  title: 'List Presence',\n                  items: const [('In Lists', true), ('Not in Lists', false)],\n                  value: filter.inLists,\n                  onChanged: (v) => filter = filter.copyWith(inLists: (v,)),\n                  highContrast: highContrast,\n                ),\n              ],\n            ),\n          ),\n        ).then(onDone);\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_header.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/staff/staff_model.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/content_header.dart';\nimport 'package:otraku/widget/table_list.dart';\n\nclass StaffHeader extends StatelessWidget {\n  const StaffHeader.withTabBar({\n    required this.id,\n    required this.imageUrl,\n    required this.staff,\n    required this.tabCtrl,\n    required this.scrollToTop,\n    required this.toggleFavorite,\n    required this.highContrast,\n  });\n\n  const StaffHeader.withoutTabBar({\n    required this.id,\n    required this.imageUrl,\n    required this.staff,\n    required this.toggleFavorite,\n    required this.highContrast,\n  }) : tabCtrl = null,\n       scrollToTop = null;\n\n  final int id;\n  final String? imageUrl;\n  final Staff? staff;\n  final TabController? tabCtrl;\n  final void Function()? scrollToTop;\n  final Future<Object?> Function() toggleFavorite;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return ContentHeader(\n      imageUrl: imageUrl ?? staff?.imageUrl,\n      imageHeightToWidthRatio: Theming.coverHtoWRatio,\n      imageHeroTag: id,\n      siteUrl: staff?.siteUrl,\n      title: staff?.preferredName,\n      details: staff != null\n          ? [\n              TableList([\n                ('Favorites', staff!.favorites.toString()),\n                if (staff!.gender != null) ('Gender', staff!.gender!),\n              ], highContrast: highContrast),\n            ]\n          : const [],\n      tabBarConfig: tabCtrl != null && scrollToTop != null\n          ? (tabCtrl: tabCtrl!, scrollToTop: scrollToTop!, tabs: tabsWithOverview)\n          : null,\n      trailingTopButtons: [if (staff != null) _FavoriteButton(staff!, toggleFavorite)],\n    );\n  }\n\n  static const tabsWithoutOverview = [Tab(text: 'Characters'), Tab(text: 'Roles')];\n\n  static const tabsWithOverview = [Tab(text: 'Overview'), ...tabsWithoutOverview];\n}\n\nclass _FavoriteButton extends StatefulWidget {\n  const _FavoriteButton(this.staff, this.toggleFavorite);\n\n  final Staff staff;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  State<_FavoriteButton> createState() => __FavoriteButtonState();\n}\n\nclass __FavoriteButtonState extends State<_FavoriteButton> {\n  @override\n  Widget build(BuildContext context) {\n    final staff = widget.staff;\n\n    return IconButton(\n      tooltip: staff.isFavorite ? 'Unfavourite' : 'Favourite',\n      icon: staff.isFavorite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border),\n      onPressed: () async {\n        setState(() => staff.isFavorite = !staff.isFavorite);\n\n        final err = await widget.toggleFavorite();\n        if (err == null) return;\n\n        setState(() => staff.isFavorite = !staff.isFavorite);\n        if (context.mounted) SnackBarExtension.show(context, err.toString());\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_item_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/staff/staff_item_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\n\nclass StaffItemGrid extends StatelessWidget {\n  const StaffItemGrid(this.items, {required this.highContrast});\n\n  final List<StaffItem> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final textHeight = lineHeight * 2 + 10;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight(\n        minWidth: 100,\n        extraHeight: textHeight,\n        rawHWRatio: Theming.coverHtoWRatio,\n      ),\n      delegate: SliverChildBuilderDelegate(\n        (_, i) => _Tile(items[i], highContrast, textHeight),\n        childCount: items.length,\n      ),\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile(this.item, this.highContrast, this.textHeight);\n\n  final StaffItem item;\n  final bool highContrast;\n  final double textHeight;\n\n  @override\n  Widget build(BuildContext context) {\n    return InkWell(\n      borderRadius: Theming.borderRadiusSmall,\n      onTap: () => context.push(Routes.staff(item.id, item.imageUrl)),\n      child: CardExtension.highContrast(highContrast)(\n        child: Column(\n          crossAxisAlignment: .stretch,\n          children: [\n            Expanded(\n              child: Hero(\n                tag: item.id,\n                child: ClipRRect(\n                  borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall),\n                  child: CachedImage(item.imageUrl),\n                ),\n              ),\n            ),\n            SizedBox(\n              height: textHeight,\n              child: Padding(\n                padding: const .all(5),\n                child: Text(item.name, maxLines: 2, overflow: .ellipsis),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_item_model.dart",
    "content": "class StaffItem {\n  const StaffItem._({required this.id, required this.name, required this.imageUrl});\n\n  factory StaffItem(Map<String, dynamic> map) => StaffItem._(\n    id: map['id'],\n    name: map['name']['userPreferred'],\n    imageUrl: map['image']['large'],\n  );\n\n  final int id;\n  final String name;\n  final String imageUrl;\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_model.dart",
    "content": "import 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/util/markdown.dart';\nimport 'package:otraku/feature/settings/settings_model.dart';\nimport 'package:otraku/util/tile_modelable.dart';\n\nclass Staff {\n  Staff._({\n    required this.id,\n    required this.preferredName,\n    required this.fullName,\n    required this.nativeName,\n    required this.altNames,\n    required this.imageUrl,\n    required this.description,\n    required this.dateOfBirth,\n    required this.dateOfDeath,\n    required this.bloodType,\n    required this.homeTown,\n    required this.gender,\n    required this.age,\n    required this.startYear,\n    required this.endYear,\n    required this.siteUrl,\n    required this.favorites,\n    required this.isFavorite,\n  });\n\n  factory Staff(Map<String, dynamic> map, PersonNaming personNaming) {\n    final names = map['name'];\n    final nameSegments = [\n      names['first'],\n      if (names['middle']?.isNotEmpty ?? false) names['middle'],\n      if (names['last']?.isNotEmpty ?? false) names['last'],\n    ];\n\n    final fullName = personNaming == .romajiWestern\n        ? nameSegments.join(' ')\n        : nameSegments.reversed.toList().join(' ');\n    final nativeName = names['native'];\n\n    final altNames = List<String>.from(names['alternative'] ?? []);\n\n    final preferredName = nativeName != null\n        ? personNaming != .native\n              ? fullName\n              : nativeName\n        : fullName;\n\n    final yearsActive = map['yearsActive'] as List?;\n\n    return Staff._(\n      id: map['id'],\n      preferredName: preferredName,\n      fullName: fullName,\n      nativeName: nativeName,\n      altNames: altNames,\n      imageUrl: map['image']['large'],\n      description: parseMarkdown(map['description'] ?? ''),\n      dateOfBirth: StringExtension.fromFuzzyDate(map['dateOfBirth']),\n      dateOfDeath: StringExtension.fromFuzzyDate(map['dateOfDeath']),\n      bloodType: map['bloodType'],\n      homeTown: map['homeTown'],\n      gender: map['gender'],\n      age: map['age']?.toString(),\n      startYear: yearsActive != null && yearsActive.isNotEmpty ? yearsActive[0].toString() : null,\n      endYear: yearsActive != null && yearsActive.length > 1 ? yearsActive[1].toString() : null,\n      siteUrl: map['siteUrl'],\n      favorites: map['favourites'] ?? 0,\n      isFavorite: map['isFavourite'] ?? false,\n    );\n  }\n\n  final int id;\n  final String preferredName;\n  final String fullName;\n  final String? nativeName;\n  final List<String> altNames;\n  final String imageUrl;\n  final String description;\n  final String? dateOfBirth;\n  final String? dateOfDeath;\n  final String? bloodType;\n  final String? homeTown;\n  final String? gender;\n  final String? age;\n  final String? startYear;\n  final String? endYear;\n  final String? siteUrl;\n  final int favorites;\n  bool isFavorite;\n}\n\nclass StaffRelations {\n  const StaffRelations({this.charactersAndMedia = const Paged(), this.roles = const Paged()});\n\n  final Paged<(StaffRelatedItem, StaffRelatedItem)> charactersAndMedia;\n  final Paged<StaffRelatedItem> roles;\n}\n\nclass StaffRelatedItem implements TileModelable {\n  const StaffRelatedItem._({\n    required this.id,\n    required this.name,\n    required this.imageUrl,\n    required this.role,\n  });\n\n  factory StaffRelatedItem.media(\n    Map<String, dynamic> map,\n    String? role,\n    ImageQuality imageQuality,\n  ) => StaffRelatedItem._(\n    id: map['id'],\n    name: map['title']['userPreferred'],\n    imageUrl: map['coverImage'][imageQuality.value],\n    role: role,\n  );\n\n  factory StaffRelatedItem.character(Map<String, dynamic> map, String? role) => StaffRelatedItem._(\n    id: map['id'],\n    name: map['name']['userPreferred'],\n    imageUrl: map['image']['large'],\n    role: role,\n  );\n\n  final int id;\n  final String name;\n  final String imageUrl;\n  final String? role;\n\n  @override\n  int get tileId => id;\n\n  @override\n  String get tileTitle => name;\n\n  @override\n  String? get tileSubtitle => role;\n\n  @override\n  String get tileImageUrl => imageUrl;\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_overview_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';\nimport 'package:otraku/feature/staff/staff_model.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/table_list.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:otraku/widget/loaders.dart';\n\nclass StaffOverviewSubview extends StatelessWidget {\n  const StaffOverviewSubview.asFragment({\n    required this.staff,\n    required this.invalidate,\n    required this.highContrast,\n    required ScrollController this.scrollCtrl,\n  }) : header = null;\n\n  const StaffOverviewSubview.withHeader({\n    required this.staff,\n    required this.invalidate,\n    required this.highContrast,\n    required Widget this.header,\n  }) : scrollCtrl = null;\n\n  final Staff staff;\n  final void Function() invalidate;\n  final Widget? header;\n  final ScrollController? scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final mediaQuery = MediaQuery.of(context);\n    final refreshControl = SliverRefreshControl(onRefresh: invalidate);\n\n    return CustomScrollView(\n      physics: Theming.bouncyPhysics,\n      controller: scrollCtrl,\n      slivers: [\n        if (header != null) ...[\n          header!,\n          MediaQuery(\n            data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)),\n            child: refreshControl,\n          ),\n        ] else\n          refreshControl,\n        SliverPadding(\n          padding: const .symmetric(horizontal: Theming.offset),\n          sliver: SliverMainAxisGroup(\n            slivers: [\n              SliverTableList([\n                ('Full', staff.fullName),\n                if (staff.nativeName != null) ('Native', staff.nativeName!),\n                ...staff.altNames.map((s) => ('Alternative', s)),\n              ], highContrast: highContrast),\n              const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)),\n              SliverTableList([\n                if (staff.dateOfBirth != null) ('Birth', staff.dateOfBirth!),\n                if (staff.dateOfDeath != null) ('Death', staff.dateOfDeath!),\n                if (staff.age != null) ('Age', staff.age!),\n                if (staff.startYear != null)\n                  ('Years Active', '${staff.startYear} - ${staff.endYear ?? 'Present'}'),\n                if (staff.homeTown != null) ('Home Town', staff.homeTown!),\n                if (staff.bloodType != null) ('Blood Type', staff.bloodType!),\n              ], highContrast: highContrast),\n              if (staff.description.isNotEmpty) ...[\n                const SliverToBoxAdapter(child: SizedBox(height: 15)),\n                HtmlContent(staff.description, renderMode: RenderMode.sliverList),\n              ],\n            ],\n          ),\n        ),\n        const SliverFooter(),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/feature/staff/staff_filter_model.dart';\nimport 'package:otraku/feature/settings/settings_provider.dart';\nimport 'package:otraku/feature/staff/staff_filter_provider.dart';\nimport 'package:otraku/feature/staff/staff_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal staffProvider = AsyncNotifierProvider.autoDispose.family<StaffNotifier, Staff, int>(\n  StaffNotifier.new,\n);\n\nfinal staffRelationsProvider = AsyncNotifierProvider.autoDispose\n    .family<StaffRelationsNotifier, StaffRelations, int>(StaffRelationsNotifier.new);\n\nclass StaffNotifier extends AsyncNotifier<Staff> {\n  StaffNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Staff> build() async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.staff, {\n      'id': arg,\n      'withInfo': true,\n    });\n\n    final personNaming = await ref.watch(\n      settingsProvider.selectAsync((settings) => settings.personNaming),\n    );\n\n    return Staff(data['Staff'], personNaming);\n  }\n\n  Future<Object?> toggleFavorite() {\n    return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, {\n      'staff': arg,\n    }).getErrorOrNull();\n  }\n}\n\nclass StaffRelationsNotifier extends AsyncNotifier<StaffRelations> {\n  StaffRelationsNotifier(this.arg);\n\n  final int arg;\n\n  late StaffFilter filter;\n\n  @override\n  FutureOr<StaffRelations> build() async {\n    filter = ref.watch(staffFilterProvider(arg));\n    return await _fetch(const StaffRelations(), null);\n  }\n\n  Future<void> fetch(bool onCharacters) async {\n    final oldState = state.value ?? const StaffRelations();\n    if (onCharacters) {\n      if (!oldState.charactersAndMedia.hasNext) return;\n    } else {\n      if (!oldState.roles.hasNext) return;\n    }\n    state = await AsyncValue.guard(() => _fetch(oldState, onCharacters));\n  }\n\n  Future<StaffRelations> _fetch(StaffRelations oldState, bool? onCharacters) async {\n    final variables = {\n      'id': arg,\n      'onList': filter.inLists,\n      'sort': filter.sort.value,\n      if (filter.ofAnime != null) 'type': filter.ofAnime! ? 'ANIME' : 'MANGA',\n    };\n\n    if (onCharacters == null) {\n      variables['withCharacters'] = true;\n      variables['withRoles'] = true;\n    } else if (onCharacters) {\n      variables['withCharacters'] = true;\n      variables['page'] = oldState.charactersAndMedia.next;\n    } else {\n      variables['withRoles'] = true;\n      variables['page'] = oldState.roles.next;\n    }\n\n    var data = await ref.read(repositoryProvider).request(GqlQuery.staff, variables);\n    data = data['Staff'];\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n\n    var charactersAndMedia = oldState.charactersAndMedia;\n    var roles = oldState.roles;\n\n    if (onCharacters == null || onCharacters) {\n      final map = data['characterMedia'];\n      final items = <(StaffRelatedItem, StaffRelatedItem)>[];\n      for (final m in map['edges']) {\n        final media = StaffRelatedItem.media(\n          m['node'],\n          StringExtension.tryNoScreamingSnakeCase(m['node']['format']),\n          imageQuality,\n        );\n\n        for (final c in m['characters']) {\n          if (c == null) continue;\n\n          items.add((\n            StaffRelatedItem.character(\n              c,\n              StringExtension.tryNoScreamingSnakeCase(m['characterRole']),\n            ),\n            media,\n          ));\n        }\n      }\n\n      charactersAndMedia = charactersAndMedia.withNext(\n        items,\n        map['pageInfo']['hasNextPage'] ?? false,\n      );\n    }\n\n    if (onCharacters == null || !onCharacters) {\n      final map = data['staffMedia'];\n      final items = <StaffRelatedItem>[];\n      for (final s in map['edges']) {\n        items.add(StaffRelatedItem.media(s['node'], s['staffRole'], imageQuality));\n      }\n\n      roles = roles.withNext(items, map['pageInfo']['hasNextPage'] ?? false);\n    }\n\n    return StaffRelations(charactersAndMedia: charactersAndMedia, roles: roles);\n  }\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_roles_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/staff/staff_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/widget/grid/mono_relation_grid.dart';\nimport 'package:otraku/widget/paged_view.dart';\nimport 'package:otraku/feature/staff/staff_provider.dart';\n\nclass StaffRolesSubview extends StatelessWidget {\n  const StaffRolesSubview({required this.id, required this.scrollCtrl, required this.highContrast});\n\n  final int id;\n  final ScrollController scrollCtrl;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    return PagedView<StaffRelatedItem>(\n      scrollCtrl: scrollCtrl,\n      onRefresh: (invalidate) => invalidate(staffRelationsProvider(id)),\n      provider: staffRelationsProvider(\n        id,\n      ).select((s) => s.unwrapPrevious().whenData((data) => data.roles)),\n      onData: (data) => MonoRelationGrid(\n        items: data.items,\n        onTap: (item) => context.push(Routes.media(item.tileId, item.tileImageUrl)),\n        highContrast: highContrast,\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/staff/staff_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/scroll_controller_extension.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/staff/staff_header.dart';\nimport 'package:otraku/feature/staff/staff_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/dual_pane_with_tab_bar.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/feature/staff/staff_floating_actions.dart';\nimport 'package:otraku/feature/staff/staff_characters_view.dart';\nimport 'package:otraku/feature/staff/staff_overview_view.dart';\nimport 'package:otraku/feature/staff/staff_provider.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/feature/staff/staff_roles_view.dart';\n\nclass StaffView extends ConsumerStatefulWidget {\n  const StaffView(this.id, this.imageUrl);\n\n  final int id;\n  final String? imageUrl;\n\n  @override\n  ConsumerState<StaffView> createState() => _StaffViewState();\n}\n\nclass _StaffViewState extends ConsumerState<StaffView> {\n  late final _scrollCtrl = PagedController(loadMore: () {});\n\n  @override\n  void dispose() {\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    ref.listen<AsyncValue>(staffProvider(widget.id), (_, s) {\n      if (s.hasError) {\n        SnackBarExtension.show(context, 'Failed to load staff: ${s.error}');\n      }\n    });\n\n    final staff = ref.watch(staffProvider(widget.id));\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    final toggleFavorite = () => ref.read(staffProvider(widget.id).notifier).toggleFavorite();\n\n    return AdaptiveScaffold(\n      floatingAction: HidingFloatingActionButton(\n        key: const Key('filter'),\n        scrollCtrl: _scrollCtrl,\n        child: StaffFilterButton(widget.id, ref),\n      ),\n      child: switch (Theming.of(context).formFactor) {\n        .phone => _CompactView(\n          id: widget.id,\n          imageUrl: widget.imageUrl,\n          ref: ref,\n          staff: staff,\n          scrollCtrl: _scrollCtrl,\n          toggleFavorite: toggleFavorite,\n          highContrast: options.highContrast,\n        ),\n        .tablet => _LargeView(\n          id: widget.id,\n          imageUrl: widget.imageUrl,\n          ref: ref,\n          staff: staff,\n          scrollCtrl: _scrollCtrl,\n          toggleFavorite: toggleFavorite,\n          highContrast: options.highContrast,\n        ),\n      },\n    );\n  }\n}\n\nclass _CompactView extends StatefulWidget {\n  const _CompactView({\n    required this.id,\n    required this.imageUrl,\n    required this.ref,\n    required this.highContrast,\n    required this.staff,\n    required this.scrollCtrl,\n    required this.toggleFavorite,\n  });\n\n  final int id;\n  final String? imageUrl;\n  final WidgetRef ref;\n  final bool highContrast;\n  final AsyncValue<Staff> staff;\n  final PagedController scrollCtrl;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  State<_CompactView> createState() => _CompactViewState();\n}\n\nclass _CompactViewState extends State<_CompactView> with SingleTickerProviderStateMixin {\n  late final _tabCtrl = TabController(length: StaffHeader.tabsWithOverview.length, vsync: this);\n\n  @override\n  void initState() {\n    super.initState();\n    widget.scrollCtrl.loadMore = () {\n      if (_tabCtrl.index > 0) {\n        widget.ref.read(staffRelationsProvider(widget.id).notifier).fetch(_tabCtrl.index == 1);\n      }\n    };\n  }\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final mediaQuery = MediaQuery.of(context);\n\n    final header = StaffHeader.withTabBar(\n      id: widget.id,\n      imageUrl: widget.imageUrl,\n      staff: widget.staff.value,\n      tabCtrl: _tabCtrl,\n      scrollToTop: widget.scrollCtrl.scrollToTop,\n      toggleFavorite: widget.toggleFavorite,\n      highContrast: widget.highContrast,\n    );\n\n    return NestedScrollView(\n      controller: widget.scrollCtrl,\n      headerSliverBuilder: (context, _) => [header],\n      body: MediaQuery(\n        data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)),\n        child: widget.staff.unwrapPrevious().when(\n          loading: () => const Center(child: Loader()),\n          error: (_, _) => const Center(child: Text('Failed to load staff')),\n          data: (data) => _StaffTabs.withOverview(\n            id: widget.id,\n            staff: data,\n            tabCtrl: _tabCtrl,\n            highContrast: widget.highContrast,\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass _LargeView extends StatefulWidget {\n  const _LargeView({\n    required this.id,\n    required this.imageUrl,\n    required this.ref,\n    required this.highContrast,\n    required this.staff,\n    required this.scrollCtrl,\n    required this.toggleFavorite,\n  });\n\n  final int id;\n  final String? imageUrl;\n  final WidgetRef ref;\n  final bool highContrast;\n  final AsyncValue<Staff> staff;\n  final PagedController scrollCtrl;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  State<_LargeView> createState() => _LargeViewState();\n}\n\nclass _LargeViewState extends State<_LargeView> with SingleTickerProviderStateMixin {\n  late final _tabCtrl = TabController(length: StaffHeader.tabsWithoutOverview.length, vsync: this);\n\n  @override\n  void initState() {\n    super.initState();\n    widget.scrollCtrl.loadMore = () {\n      widget.ref.read(staffRelationsProvider(widget.id).notifier).fetch(_tabCtrl.index == 0);\n    };\n  }\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final header = StaffHeader.withoutTabBar(\n      id: widget.id,\n      imageUrl: widget.imageUrl,\n      staff: widget.staff.value,\n      toggleFavorite: widget.toggleFavorite,\n      highContrast: widget.highContrast,\n    );\n\n    return DualPaneWithTabBar(\n      tabCtrl: _tabCtrl,\n      scrollToTop: widget.scrollCtrl.scrollToTop,\n      tabs: StaffHeader.tabsWithoutOverview,\n      leftPane: widget.staff.unwrapPrevious().when(\n        loading: () => CustomScrollView(\n          physics: Theming.bouncyPhysics,\n          slivers: [\n            header,\n            const SliverFillRemaining(child: Center(child: Loader())),\n          ],\n        ),\n        error: (_, _) => CustomScrollView(\n          physics: Theming.bouncyPhysics,\n          slivers: [\n            header,\n            const SliverFillRemaining(child: Center(child: Text('Failed to load staff'))),\n          ],\n        ),\n        data: (data) => StaffOverviewSubview.withHeader(\n          staff: data,\n          header: header,\n          invalidate: () => widget.ref.invalidate(staffProvider(widget.id)),\n          highContrast: widget.highContrast,\n        ),\n      ),\n      rightPane: widget.staff.unwrapPrevious().maybeWhen(\n        data: (data) => _StaffTabs.withoutOverview(\n          id: widget.id,\n          staff: data,\n          tabCtrl: _tabCtrl,\n          scrollCtrl: widget.scrollCtrl,\n          highContrast: widget.highContrast,\n        ),\n        orElse: () => const SizedBox(),\n      ),\n    );\n  }\n}\n\nclass _StaffTabs extends ConsumerStatefulWidget {\n  const _StaffTabs.withOverview({\n    required this.id,\n    required this.staff,\n    required this.tabCtrl,\n    required this.highContrast,\n  }) : withOverview = true,\n       scrollCtrl = null;\n\n  const _StaffTabs.withoutOverview({\n    required this.id,\n    required this.staff,\n    required this.tabCtrl,\n    required this.highContrast,\n    required ScrollController this.scrollCtrl,\n  }) : withOverview = false;\n\n  final int id;\n  final Staff staff;\n  final TabController tabCtrl;\n  final ScrollController? scrollCtrl;\n  final bool highContrast;\n  final bool withOverview;\n\n  @override\n  ConsumerState<_StaffTabs> createState() => __StaffViewContentState();\n}\n\nclass __StaffViewContentState extends ConsumerState<_StaffTabs> {\n  late final ScrollController _scrollCtrl;\n  double _lastMaxExtent = 0;\n\n  @override\n  void initState() {\n    super.initState();\n    _scrollCtrl =\n        widget.scrollCtrl ??\n        context.findAncestorStateOfType<NestedScrollViewState>()!.innerController;\n\n    _scrollCtrl.addListener(_scrollListener);\n    widget.tabCtrl.addListener(_tabListener);\n  }\n\n  @override\n  void dispose() {\n    _scrollCtrl.removeListener(_scrollListener);\n    widget.tabCtrl.removeListener(_tabListener);\n    super.dispose();\n  }\n\n  void _tabListener() {\n    _lastMaxExtent = 0;\n\n    // This is a workaround for an issue with [NestedScrollView].\n    // If you switch to a tab with pagination, where the content\n    // doesn't fill the view, the scroll controller has it's maximum\n    // extent set to 0 and the loading of a next page of items is not triggered.\n    // This is why we need to manually load the second page.\n    if (!widget.tabCtrl.indexIsChanging && _scrollCtrl.hasClients) {\n      final pos = _scrollCtrl.positions.last;\n      if (pos.minScrollExtent == pos.maxScrollExtent) _loadNextPage();\n    }\n  }\n\n  void _scrollListener() {\n    final pos = _scrollCtrl.positions.last;\n    if (pos.pixels < pos.maxScrollExtent - 100) return;\n    if (_lastMaxExtent == pos.maxScrollExtent) return;\n\n    _lastMaxExtent = pos.maxScrollExtent;\n    _loadNextPage();\n  }\n\n  void _loadNextPage() {\n    final index = widget.withOverview ? widget.tabCtrl.index : widget.tabCtrl.index + 1;\n\n    if (index > 0) {\n      ref.read(staffRelationsProvider(widget.id).notifier).fetch(index == 1);\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    ref.watch(staffRelationsProvider(widget.id).select((_) => null));\n\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    return TabBarView(\n      controller: widget.tabCtrl,\n      children: [\n        if (widget.withOverview)\n          ConstrainedView(\n            padded: false,\n            child: StaffOverviewSubview.asFragment(\n              staff: widget.staff,\n              scrollCtrl: _scrollCtrl,\n              invalidate: () => ref.invalidate(staffProvider(widget.id)),\n              highContrast: widget.highContrast,\n            ),\n          ),\n        StaffCharactersSubview(\n          id: widget.id,\n          scrollCtrl: _scrollCtrl,\n          highContrast: options.highContrast,\n        ),\n        StaffRolesSubview(\n          id: widget.id,\n          scrollCtrl: _scrollCtrl,\n          highContrast: options.highContrast,\n        ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/statistics/charts.dart",
    "content": "import 'dart:math' as math;\n\nimport 'package:flutter/material.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/util/theming.dart';\n\nclass BarChart extends StatelessWidget {\n  const BarChart({required this.title, required this.names, required this.values, this.toolbar})\n    : assert(names.length == values.length);\n\n  final String title;\n  final List<String> names;\n  final List<num> values;\n  final Widget? toolbar;\n\n  @override\n  Widget build(BuildContext context) {\n    final colorScheme = ColorScheme.of(context);\n    final textTheme = TextTheme.of(context);\n\n    return LayoutBuilder(\n      builder: (context, constraints) {\n        final maxBarWidth = constraints.maxWidth;\n        double scale(num value) => value > 0 ? math.log(value + 0.1) : 0;\n\n        final maxValue = values.fold<num>(0.0, (prev, element) => element > prev ? element : prev);\n        final scaledMaxValue = scale(maxValue);\n\n        final totalValue = values.fold(0.0, (sum, value) => sum + value);\n\n        return Column(\n          crossAxisAlignment: .start,\n          children: [\n            Padding(\n              padding: const .symmetric(vertical: 5),\n              child: Text(title, style: textTheme.titleSmall),\n            ),\n            if (toolbar != null) ...[\n              SizedBox(width: double.infinity, child: toolbar!),\n              const SizedBox(height: Theming.offset),\n            ],\n            for (int i = 0; i < names.length; i++)\n              Padding(\n                padding: const .symmetric(vertical: 3),\n                child: Column(\n                  crossAxisAlignment: .start,\n                  spacing: 1,\n                  children: [\n                    Row(\n                      children: [\n                        Expanded(\n                          child: Text(names[i], style: textTheme.labelMedium, textAlign: .left),\n                        ),\n                        Expanded(\n                          child: Text(\n                            \"${(values[i] / totalValue * 100).toStringAsFixed(1)}%\",\n                            style: textTheme.labelMedium,\n                            textAlign: .center,\n                          ),\n                        ),\n                        Expanded(\n                          child: Text(\n                            \"${values[i]}\",\n                            style: textTheme.labelMedium,\n                            textAlign: .right,\n                          ),\n                        ),\n                      ],\n                    ),\n                    Container(\n                      height: 10,\n                      width: maxBarWidth,\n                      decoration: BoxDecoration(\n                        borderRadius: Theming.borderRadiusSmall,\n                        color: colorScheme.surfaceContainerLowest,\n                        border: .all(color: colorScheme.outlineVariant, width: 1),\n                      ),\n                      alignment: .centerLeft,\n                      child: AnimatedContainer(\n                        duration: const Duration(milliseconds: 200),\n                        width: (scale(values[i]) / scaledMaxValue) * maxBarWidth,\n                        decoration: BoxDecoration(\n                          borderRadius: Theming.borderRadiusSmall,\n                          gradient: LinearGradient(\n                            begin: .centerLeft,\n                            end: .centerRight,\n                            colors: [colorScheme.primaryContainer, colorScheme.primary],\n                          ),\n                        ),\n                      ),\n                    ),\n                  ],\n                ),\n              ),\n          ],\n        );\n      },\n    );\n  }\n}\n\nclass PieChart extends StatelessWidget {\n  const PieChart({\n    required this.title,\n    required this.names,\n    required this.values,\n    required this.highContrast,\n  }) : assert(names.length == values.length);\n\n  final String title;\n  final List<String> names;\n  final List<int> values;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final colorScheme = ColorScheme.of(context);\n\n    final container = CardExtension.highContrast(highContrast)(\n      child: Row(\n        mainAxisSize: MediaQuery.sizeOf(context).width > 420 ? .min : .max,\n        mainAxisAlignment: .spaceBetween,\n        spacing: Theming.offset,\n        children: [\n          Expanded(\n            child: Padding(\n              padding: const .all(Theming.offset),\n              child: AspectRatio(\n                aspectRatio: 1,\n                child: DecoratedBox(\n                  decoration: BoxDecoration(\n                    shape: .circle,\n                    gradient: RadialGradient(\n                      center: const Alignment(-0.5, -0.5),\n                      radius: 0.8,\n                      colors: [colorScheme.primary, colorScheme.primary.withAlpha(100)],\n                      stops: const [0.5, 1.0],\n                    ),\n                  ),\n                  child: CustomPaint(foregroundPainter: _PieLines(colorScheme.surface, values)),\n                ),\n              ),\n            ),\n          ),\n          Expanded(\n            child: ListView.builder(\n              padding: const .only(top: 5, bottom: 5, right: Theming.offset),\n              itemCount: names.length,\n              itemBuilder: (context, i) => Padding(\n                padding: const .symmetric(vertical: 5),\n                child: Row(\n                  spacing: 5,\n                  children: [\n                    Expanded(child: Text(names[i])),\n                    Text(values[i].toString(), style: TextTheme.of(context).labelMedium),\n                  ],\n                ),\n              ),\n            ),\n          ),\n        ],\n      ),\n    );\n\n    return Column(\n      mainAxisSize: .min,\n      crossAxisAlignment: .start,\n      spacing: 5,\n      children: [\n        Text(title, style: TextTheme.of(context).titleSmall),\n        Expanded(child: container),\n      ],\n    );\n  }\n}\n\n/// The lines drawn over the [PieChart] to\n/// make the [categories] distinguishable.\nclass _PieLines extends CustomPainter {\n  _PieLines(this.colour, this.categories);\n\n  final Color colour;\n  final List<int> categories;\n\n  @override\n  void paint(Canvas canvas, Size size) {\n    final paint = Paint()\n      ..color = colour\n      ..style = PaintingStyle.stroke\n      ..strokeJoin = StrokeJoin.round\n      ..strokeCap = StrokeCap.round\n      ..strokeWidth = 2;\n\n    double total = 0.0;\n    for (final c in categories) {\n      total += c;\n    }\n\n    final radius = math.min(size.width, size.height) / 2;\n    final center = Offset(radius, radius);\n    final offset = math.pi * 2 - categories.length * 0.05;\n    double angle = math.pi;\n\n    for (int i = 0; i < categories.length; i++) {\n      angle -= 0.05 + (categories[i] / total) * offset;\n\n      final point = Offset(\n        center.dx + radius * math.sin(angle),\n        center.dy + radius * math.cos(angle),\n      );\n\n      canvas.drawLine(center, point, paint);\n    }\n  }\n\n  @override\n  bool shouldRepaint(covariant _PieLines oldDelegate) => false;\n}\n"
  },
  {
    "path": "lib/feature/statistics/statistics_model.dart",
    "content": "import 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/feature/media/media_models.dart';\n\nclass Statistics {\n  Statistics._({\n    required this.count,\n    required this.meanScore,\n    required this.standardDeviation,\n    required this.partsConsumed,\n    required this.amountConsumed,\n    required this.scores,\n    required this.lengths,\n    required this.formats,\n    required this.statuses,\n    required this.countries,\n  });\n\n  factory Statistics(Map<String, dynamic> map, bool ofAnime) {\n    final scores = <AmountStatistics>[];\n    final lengths = <AmountStatistics>[];\n    final formats = <TypeStatistics>[];\n    final statuses = <TypeStatistics>[];\n    final countries = <TypeStatistics>[];\n\n    for (final s in map['scores']) {\n      scores.add(AmountStatistics(s, 'score', ofAnime));\n    }\n    for (final l in map['lengths']) {\n      lengths.add(AmountStatistics(l, 'length', ofAnime));\n    }\n    for (final f in map['formats']) {\n      formats.add(TypeStatistics(f, 'format'));\n    }\n    for (final s in map['statuses']) {\n      statuses.add(TypeStatistics(s, 'status'));\n    }\n    for (final c in map['countries']) {\n      c['country'] = OriginCountry.fromCode(c['country'])?.label;\n      countries.add(TypeStatistics(c, 'country'));\n    }\n\n    // The backend can't sort them by length, so it has to be done locally.\n    lengths.sort((a, b) {\n      if (a.type == '?') return 1;\n      if (b.type == '?') return -1;\n\n      if (a.type[a.type.length - 1] == '+') return 1;\n      if (b.type[b.type.length - 1] == '+') return -1;\n\n      if (a.type.length > b.type.length) return 1;\n      if (a.type.length < b.type.length) return -1;\n\n      return a.type.compareTo(b.type);\n    });\n\n    return Statistics._(\n      count: map['count'],\n      meanScore: map['meanScore'].toDouble(),\n      standardDeviation: map['standardDeviation'].toDouble(),\n      partsConsumed: ofAnime ? map['episodesWatched'] : map['chaptersRead'],\n      amountConsumed: ofAnime ? map['minutesWatched'] : map['volumesRead'],\n      scores: scores,\n      lengths: lengths,\n      formats: formats,\n      statuses: statuses,\n      countries: countries,\n    );\n  }\n\n  final int count;\n  final double meanScore;\n  final double standardDeviation;\n  final int partsConsumed;\n  final int amountConsumed;\n  final List<AmountStatistics> scores;\n  final List<AmountStatistics> lengths;\n  final List<TypeStatistics> formats;\n  final List<TypeStatistics> statuses;\n  final List<TypeStatistics> countries;\n}\n\nclass AmountStatistics {\n  AmountStatistics._({\n    required this.count,\n    required this.meanScore,\n    required this.amount,\n    required this.type,\n  });\n\n  factory AmountStatistics(Map<String, dynamic> map, String key, bool ofAnime) =>\n      AmountStatistics._(\n        count: map['count'],\n        meanScore: map['meanScore'].toDouble(),\n        amount: ofAnime ? map['minutesWatched'] ~/ 60 : map['chaptersRead'],\n        type: (map[key] ?? '?').toString(),\n      );\n\n  final int count;\n  final double meanScore;\n  final int amount;\n  final String type;\n}\n\nclass TypeStatistics {\n  TypeStatistics._({\n    required this.count,\n    required this.meanScore,\n    required this.hoursWatched,\n    required this.chaptersRead,\n    required this.value,\n  });\n\n  factory TypeStatistics(Map<String, dynamic> map, String key) => TypeStatistics._(\n    count: map['count'],\n    meanScore: map['meanScore'].toDouble(),\n    hoursWatched: map['minutesWatched'] ~/ 60,\n    chaptersRead: map['chaptersRead'],\n    value: (map[key] as String).noScreamingSnakeCase,\n  );\n\n  final int count;\n  final double meanScore;\n  final int hoursWatched;\n  final int chaptersRead;\n  final String value;\n}\n"
  },
  {
    "path": "lib/feature/statistics/statistics_view.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/extension/scroll_controller_extension.dart';\nimport 'package:otraku/feature/statistics/statistics_model.dart';\nimport 'package:otraku/feature/user/user_model.dart';\nimport 'package:otraku/feature/user/user_providers.dart';\nimport 'package:otraku/feature/statistics/charts.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/loaders.dart';\n\nclass StatisticsView extends StatefulWidget {\n  const StatisticsView(this.id);\n\n  final int id;\n\n  @override\n  State<StatisticsView> createState() => _StatisticsViewState();\n}\n\nclass _StatisticsViewState extends State<StatisticsView> with SingleTickerProviderStateMixin {\n  late final tag = idUserTag(widget.id);\n  late final _tabCtrl = TabController(length: 2, vsync: this);\n  final _scrollCtrl = ScrollController();\n\n  int _primaryBarChartTab = 0;\n  int _secondaryBarChartTab = 0;\n\n  @override\n  void initState() {\n    super.initState();\n    _tabCtrl.addListener(() => setState(() {}));\n  }\n\n  @override\n  void dispose() {\n    _tabCtrl.dispose();\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final child = Consumer(\n      builder: (context, ref, _) {\n        ref.listen<AsyncValue<User>>(\n          userProvider(tag),\n          (_, s) =>\n              s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())),\n        );\n\n        final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n        return ref\n            .watch(userProvider(tag))\n            .when(\n              loading: () => const Center(child: Loader()),\n              error: (_, _) => const Center(child: Text('Failed to load statistics')),\n              data: (data) {\n                return TabBarView(\n                  controller: _tabCtrl,\n                  children: [\n                    ConstrainedView(\n                      child: _StatisticsView(\n                        statistics: data.animeStats,\n                        ofAnime: true,\n                        scrollCtrl: _scrollCtrl,\n                        primaryBarChartTab: () => _primaryBarChartTab,\n                        secondaryBarChartTab: () => _secondaryBarChartTab,\n                        onPrimaryTabChanged: (i) => _primaryBarChartTab = i,\n                        onSecondaryTabChanged: (i) => _secondaryBarChartTab = i,\n                        highContrast: options.highContrast,\n                      ),\n                    ),\n                    ConstrainedView(\n                      child: _StatisticsView(\n                        statistics: data.mangaStats,\n                        ofAnime: false,\n                        scrollCtrl: _scrollCtrl,\n                        primaryBarChartTab: () => _primaryBarChartTab,\n                        secondaryBarChartTab: () => _secondaryBarChartTab,\n                        onPrimaryTabChanged: (i) => _primaryBarChartTab = i,\n                        onSecondaryTabChanged: (i) => _secondaryBarChartTab = i,\n                        highContrast: options.highContrast,\n                      ),\n                    ),\n                  ],\n                );\n              },\n            );\n      },\n    );\n\n    return AdaptiveScaffold(\n      topBar: _tabCtrl.index == 0\n          ? const TopBar(key: Key('0'), title: 'Anime Statistics')\n          : const TopBar(key: Key('1'), title: 'Manga Statistics'),\n      navigationConfig: NavigationConfig(\n        selected: _tabCtrl.index,\n        onChanged: (i) => _tabCtrl.index = i,\n        onSame: (_) => _scrollCtrl.scrollToTop(),\n        items: const {'Anime': Ionicons.film_outline, 'Manga': Ionicons.book_outline},\n      ),\n      child: child,\n    );\n  }\n}\n\nclass _StatisticsView extends StatelessWidget {\n  const _StatisticsView({\n    required this.statistics,\n    required this.ofAnime,\n    required this.scrollCtrl,\n    required this.primaryBarChartTab,\n    required this.secondaryBarChartTab,\n    required this.onPrimaryTabChanged,\n    required this.onSecondaryTabChanged,\n    required this.highContrast,\n  });\n\n  final Statistics statistics;\n  final bool ofAnime;\n  final ScrollController scrollCtrl;\n  final int Function() primaryBarChartTab;\n  final int Function() secondaryBarChartTab;\n  final void Function(int) onPrimaryTabChanged;\n  final void Function(int) onSecondaryTabChanged;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    const spacing = SliverToBoxAdapter(child: SizedBox(height: Theming.offset));\n\n    return CustomScrollView(\n      controller: scrollCtrl,\n      slivers: [\n        SliverToBoxAdapter(\n          child: SizedBox(height: MediaQuery.paddingOf(context).top + Theming.offset),\n        ),\n        _Details(statistics, ofAnime, highContrast),\n        if (statistics.scores.isNotEmpty) ...[\n          spacing,\n          _BarChart(\n            title: 'Score',\n            statistics: statistics.scores,\n            ofAnime: ofAnime,\n            full: false,\n            initialTab: primaryBarChartTab(),\n            onTabChanged: onPrimaryTabChanged,\n          ),\n        ],\n        if (statistics.lengths.isNotEmpty) ...[\n          spacing,\n          _BarChart(\n            title: ofAnime ? 'Episodes' : 'Chapters',\n            statistics: statistics.lengths,\n            ofAnime: ofAnime,\n            full: true,\n            initialTab: secondaryBarChartTab(),\n            onTabChanged: onSecondaryTabChanged,\n          ),\n        ],\n        if (statistics.count > 0) ...[\n          spacing,\n          SliverGrid(\n            gridDelegate: const SliverGridDelegateWithMinWidthAndFixedHeight(\n              minWidth: 340,\n              height: 200,\n            ),\n            delegate: SliverChildListDelegate([\n              _PieChart('Format Distribution', statistics.formats, highContrast),\n              _PieChart('Status Distribution', statistics.statuses, highContrast),\n              _PieChart('Country Distribution', statistics.countries, highContrast),\n            ]),\n          ),\n        ],\n        const SliverFooter(),\n      ],\n    );\n  }\n}\n\nclass _Details extends StatelessWidget {\n  _Details(Statistics statistics, bool ofAnime, this.highContrast) {\n    subtitles.add(statistics.count);\n    subtitles.add(statistics.partsConsumed);\n\n    if (ofAnime) {\n      subtitles.add(((statistics.amountConsumed / 1440) * 10).round() / 10);\n      icons.add(Ionicons.film_outline);\n      icons.add(Ionicons.play_outline);\n      icons.add(Ionicons.calendar_clear_outline);\n      titles.add('Total Anime');\n      titles.add('Episodes Watched');\n      titles.add('Days Watched');\n    } else {\n      subtitles.add(statistics.amountConsumed);\n      icons.add(Ionicons.book_outline);\n      icons.add(Ionicons.reader_outline);\n      icons.add(Ionicons.bookmark_outline);\n      titles.add('Total Manga');\n      titles.add('Chapters Read');\n      titles.add('Volumes Read');\n    }\n    icons.add(Ionicons.star_half_outline);\n    icons.add(Ionicons.calculator_outline);\n    titles.add('Mean Score');\n    titles.add('Standard Deviation');\n    subtitles.add(statistics.meanScore);\n    subtitles.add(statistics.standardDeviation);\n  }\n\n  final icons = <IconData>[];\n  final titles = <String>[];\n  final subtitles = <num>[];\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final textTheme = TextTheme.of(context);\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);\n    final tileHeight = max(bodyMediumLineHeight + labelMediumLineHeight, Theming.iconBig) + 10;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(\n        minWidth: 190,\n        height: tileHeight,\n        mainAxisSpacing: 10,\n        crossAxisSpacing: 10,\n      ),\n      delegate: SliverChildBuilderDelegate(\n        childCount: titles.length,\n        (context, i) => Tooltip(\n          message: titles[i],\n          triggerMode: .tap,\n          child: CardExtension.highContrast(highContrast)(\n            child: Padding(\n              padding: const .symmetric(horizontal: Theming.offset, vertical: 5),\n              child: Row(\n                spacing: Theming.offset,\n                children: [\n                  Icon(icons[i], size: Theming.iconBig),\n                  Expanded(\n                    child: Column(\n                      mainAxisAlignment: .center,\n                      crossAxisAlignment: .start,\n                      children: [\n                        Expanded(\n                          child: Text(\n                            titles[i],\n                            style: TextTheme.of(context).labelMedium,\n                            overflow: .ellipsis,\n                            maxLines: 1,\n                          ),\n                        ),\n                        Text(subtitles[i].toString()),\n                      ],\n                    ),\n                  ),\n                ],\n              ),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass _BarChart extends StatefulWidget {\n  const _BarChart({\n    required this.statistics,\n    required this.title,\n    required this.initialTab,\n    required this.ofAnime,\n    required this.full,\n    required this.onTabChanged,\n  });\n\n  final List<AmountStatistics> statistics;\n  final String title;\n  final int initialTab;\n  final bool ofAnime;\n  final bool full;\n  final void Function(int) onTabChanged;\n\n  @override\n  State<_BarChart> createState() => _BarChartState();\n}\n\nclass _BarChartState extends State<_BarChart> {\n  late int _tab = widget.initialTab;\n\n  @override\n  Widget build(BuildContext context) {\n    late List<num> values;\n    if (_tab == 0) {\n      values = widget.statistics.map((s) => s.count).toList();\n    } else if (_tab == 1) {\n      values = widget.statistics.map((s) => s.amount).toList();\n    } else {\n      values = widget.statistics.map((s) => s.meanScore).toList();\n    }\n\n    return SliverToBoxAdapter(\n      child: BarChart(\n        title: widget.title,\n        toolbar: SegmentedButton(\n          segments: [\n            const ButtonSegment(\n              value: 0,\n              label: Text('Titles'),\n              icon: Icon(Icons.numbers_outlined),\n            ),\n            if (widget.ofAnime)\n              const ButtonSegment(\n                value: 1,\n                label: Text('Hours'),\n                icon: Icon(Icons.hourglass_bottom_outlined),\n              )\n            else\n              const ButtonSegment(\n                value: 1,\n                label: Text('Chapters'),\n                icon: Icon(Icons.hourglass_bottom_outlined),\n              ),\n            if (widget.full && widget.statistics.any((s) => s.meanScore > 0))\n              const ButtonSegment(\n                value: 2,\n                label: Text('Score'),\n                icon: Icon(Icons.star_half_outlined),\n              ),\n          ],\n          selected: {_tab},\n          onSelectionChanged: (v) {\n            setState(() => _tab = v.first);\n            widget.onTabChanged(v.first);\n          },\n        ),\n        names: widget.statistics.map((s) => s.type).toList(),\n        values: values,\n      ),\n    );\n  }\n}\n\nclass _PieChart extends StatelessWidget {\n  const _PieChart(this.title, this.stats, this.highContrast);\n\n  final String title;\n  final List<TypeStatistics> stats;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final names = stats.map((s) => s.value).toList();\n    final values = stats.map((s) => s.count).toList();\n    return PieChart(title: title, names: names, values: values, highContrast: highContrast);\n  }\n}\n"
  },
  {
    "path": "lib/feature/studio/studio_filter_model.dart",
    "content": "import 'package:otraku/feature/media/media_models.dart';\n\nclass StudioFilter {\n  const StudioFilter({this.sort = .startDateDesc, this.inLists, this.isMain});\n\n  final MediaSort sort;\n  final bool? inLists;\n  final bool? isMain;\n\n  StudioFilter copyWith({MediaSort? sort, (bool?,)? inLists, (bool?,)? isMain}) => StudioFilter(\n    sort: sort ?? this.sort,\n    inLists: inLists == null ? this.inLists : inLists.$1,\n    isMain: isMain == null ? this.isMain : isMain.$1,\n  );\n}\n"
  },
  {
    "path": "lib/feature/studio/studio_filter_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/studio/studio_filter_model.dart';\n\nfinal studioFilterProvider = NotifierProvider.autoDispose\n    .family<StudioFilterNotifier, StudioFilter, int>(StudioFilterNotifier.new);\n\nclass StudioFilterNotifier extends Notifier<StudioFilter> {\n  StudioFilterNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  StudioFilter build() => const StudioFilter();\n\n  @override\n  set state(StudioFilter newState) => super.state = newState;\n}\n"
  },
  {
    "path": "lib/feature/studio/studio_floating_actions.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/widget/input/chip_selector.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/studio/studio_filter_provider.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass StudioFilterButton extends StatelessWidget {\n  const StudioFilterButton(this.id, this.ref);\n\n  final int id;\n  final WidgetRef ref;\n\n  @override\n  Widget build(BuildContext context) {\n    return FloatingActionButton(\n      tooltip: 'Filter',\n      heroTag: 'filter',\n      child: const Icon(Ionicons.funnel_outline),\n      onPressed: () {\n        var filter = ref.read(studioFilterProvider(id));\n        final onDone = (_) => ref.read(studioFilterProvider(id).notifier).state = filter;\n        final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast));\n\n        showSheet(\n          context,\n          SimpleSheet(\n            initialHeight: Theming.normalTapTarget * 4 + MediaQuery.paddingOf(context).bottom + 40,\n            builder: (context, scrollCtrl) => ListView(\n              controller: scrollCtrl,\n              physics: Theming.bouncyPhysics,\n              padding: const .symmetric(horizontal: Theming.offset, vertical: 20),\n              children: [\n                ChipSelector.ensureSelected(\n                  title: 'Sort',\n                  items: MediaSort.values.map((v) => (v.label, v)).toList(),\n                  value: filter.sort,\n                  onChanged: (v) => filter = filter.copyWith(sort: v),\n                  highContrast: highContrast,\n                ),\n                ChipSelector(\n                  title: 'List Presence',\n                  items: const [('In Lists', true), ('Not in Lists', false)],\n                  value: filter.inLists,\n                  onChanged: (v) => filter = filter.copyWith(inLists: (v,)),\n                  highContrast: highContrast,\n                ),\n                ChipSelector(\n                  title: 'Main Studio',\n                  items: const [('Is Main', true), ('Is Not Main', false)],\n                  value: filter.isMain,\n                  onChanged: (v) => filter = filter.copyWith(isMain: (v,)),\n                  highContrast: highContrast,\n                ),\n              ],\n            ),\n          ),\n        ).then(onDone);\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/studio/studio_header.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/studio/studio_model.dart';\nimport 'package:otraku/widget/layout/content_header.dart';\n\nclass StudioHeader extends StatelessWidget {\n  const StudioHeader({\n    required this.id,\n    required this.name,\n    required this.studio,\n    required this.toggleFavorite,\n  });\n\n  final int id;\n  final String? name;\n  final Studio? studio;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  Widget build(BuildContext context) {\n    final name = studio?.name ?? this.name;\n\n    return CustomContentHeader(\n      title: name,\n      siteUrl: studio?.siteUrl,\n      trailingTopButtons: studio != null ? [_FavoriteButton(studio!, toggleFavorite)] : const [],\n      content: PreferredSize(\n        preferredSize: const Size.fromHeight(80),\n        child: Column(\n          crossAxisAlignment: .stretch,\n          children: [\n            if (name != null)\n              Flexible(\n                child: GestureDetector(\n                  onTap: () => SnackBarExtension.copy(context, name),\n                  child: Hero(\n                    tag: id,\n                    child: Text(\n                      name,\n                      textAlign: .center,\n                      overflow: .ellipsis,\n                      style: TextTheme.of(context).titleMedium,\n                    ),\n                  ),\n                ),\n              ),\n            if (studio != null)\n              Flexible(child: Text('${studio!.favorites} Favorites', textAlign: .center)),\n          ],\n        ),\n      ),\n    );\n  }\n}\n\nclass _FavoriteButton extends StatefulWidget {\n  const _FavoriteButton(this.studio, this.toggleFavorite);\n\n  final Studio studio;\n  final Future<Object?> Function() toggleFavorite;\n\n  @override\n  State<_FavoriteButton> createState() => __FavoriteButtonState();\n}\n\nclass __FavoriteButtonState extends State<_FavoriteButton> {\n  @override\n  Widget build(BuildContext context) {\n    final studio = widget.studio;\n\n    return IconButton(\n      tooltip: studio.isFavorite ? 'Unfavourite' : 'Favourite',\n      icon: studio.isFavorite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border),\n      onPressed: () async {\n        setState(() => studio.isFavorite = !studio.isFavorite);\n\n        final err = await widget.toggleFavorite();\n        if (err == null) return;\n\n        setState(() => studio.isFavorite = !studio.isFavorite);\n        if (context.mounted) SnackBarExtension.show(context, err.toString());\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/studio/studio_item_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/studio/studio_item_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\n\nclass StudioItemGrid extends StatelessWidget {\n  const StudioItemGrid(this.items, {required this.highContrast});\n\n  final List<StudioItem> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(\n        minWidth: 230,\n        height: lineHeight + 20,\n        mainAxisSpacing: 10,\n        crossAxisSpacing: 10,\n      ),\n      delegate: SliverChildBuilderDelegate(\n        childCount: items.length,\n        (_, i) => InkWell(\n          borderRadius: Theming.borderRadiusSmall,\n          onTap: () => context.push(Routes.studio(items[i].id, items[i].name)),\n          child: CardExtension.highContrast(highContrast)(\n            child: Padding(\n              padding: Theming.paddingAll,\n              child: Hero(\n                tag: items[i].id,\n                child: Text(\n                  items[i].name,\n                  style: TextTheme.of(context).bodyMedium,\n                  overflow: .ellipsis,\n                  maxLines: 1,\n                ),\n              ),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/studio/studio_item_model.dart",
    "content": "class StudioItem {\n  const StudioItem._({required this.id, required this.name});\n\n  factory StudioItem(Map<String, dynamic> map) => StudioItem._(id: map['id'], name: map['name']);\n\n  final int id;\n  final String name;\n}\n"
  },
  {
    "path": "lib/feature/studio/studio_model.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\n\nclass Studio {\n  Studio._({\n    required this.id,\n    required this.name,\n    required this.siteUrl,\n    required this.favorites,\n    required this.isFavorite,\n  });\n\n  factory Studio(Map<String, dynamic> map) => Studio._(\n    id: map['id'],\n    name: map['name'],\n    siteUrl: map['siteUrl'],\n    favorites: map['favourites'] ?? 0,\n    isFavorite: map['isFavourite'] ?? false,\n  );\n\n  final int id;\n  final String name;\n  final String siteUrl;\n  final int favorites;\n  bool isFavorite;\n}\n\nclass StudioMedia {\n  const StudioMedia._({\n    required this.id,\n    required this.title,\n    required this.cover,\n    required this.format,\n    required this.releaseStatus,\n    required this.weightedAverageScore,\n    required this.entryStatus,\n    required this.startDate,\n  });\n\n  factory StudioMedia(Map<String, dynamic> map, ImageQuality imageQuality) => StudioMedia._(\n    id: map['id'],\n    title: map['title']['userPreferred'],\n    cover: map['coverImage'][imageQuality.value],\n    format: MediaFormat.from(map['format']),\n    releaseStatus: ReleaseStatus.from(map['status']),\n    weightedAverageScore: map['averageScore'] ?? 0,\n    entryStatus: ListStatus.from(map['mediaListEntry']?['status']),\n    startDate: DateTimeExtension.fuzzyDateString(map['startDate']),\n  );\n\n  final int id;\n  final String title;\n  final String cover;\n  final MediaFormat? format;\n  final ReleaseStatus? releaseStatus;\n  final int weightedAverageScore;\n  final ListStatus? entryStatus;\n  final String? startDate;\n}\n"
  },
  {
    "path": "lib/feature/studio/studio_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/feature/studio/studio_filter_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/feature/studio/studio_filter_provider.dart';\nimport 'package:otraku/feature/studio/studio_model.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal studioProvider = AsyncNotifierProvider.autoDispose.family<StudioNotifier, Studio, int>(\n  StudioNotifier.new,\n);\n\nfinal studioMediaProvider = AsyncNotifierProvider.autoDispose\n    .family<StudioMediaNotifier, Paged<StudioMedia>, int>(StudioMediaNotifier.new);\n\nclass StudioNotifier extends AsyncNotifier<Studio> {\n  StudioNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Studio> build() async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.studio, {\n      'id': arg,\n      'withInfo': true,\n    });\n    return Studio(data['Studio']);\n  }\n\n  Future<Object?> toggleFavorite() {\n    return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, {\n      'studio': arg,\n    }).getErrorOrNull();\n  }\n}\n\nclass StudioMediaNotifier extends AsyncNotifier<Paged<StudioMedia>> {\n  StudioMediaNotifier(this.arg);\n\n  final int arg;\n\n  late StudioFilter filter;\n\n  @override\n  FutureOr<Paged<StudioMedia>> build() async {\n    filter = ref.watch(studioFilterProvider(arg));\n    return await _fetch(const Paged());\n  }\n\n  Future<void> fetch() async {\n    final oldState = state.value ?? const Paged();\n    if (!oldState.hasNext) return;\n    state = await AsyncValue.guard(() => _fetch(oldState));\n  }\n\n  Future<Paged<StudioMedia>> _fetch(Paged<StudioMedia> oldState) async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.studio, {\n      'id': arg,\n      'withMedia': true,\n      'page': oldState.next,\n      'sort': filter.sort.value,\n      'onList': filter.inLists,\n      if (filter.isMain != null) 'isMain': filter.isMain,\n    });\n\n    final imageQuality = ref.read(persistenceProvider).options.imageQuality;\n    final map = data['Studio']['media'];\n    final items = <StudioMedia>[];\n    for (final m in map['nodes']) {\n      items.add(StudioMedia(m, imageQuality));\n    }\n\n    return oldState.withNext(items, map['pageInfo']['hasNextPage'] ?? false);\n  }\n}\n"
  },
  {
    "path": "lib/feature/studio/studio_view.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/media/media_route_tile.dart';\nimport 'package:otraku/feature/studio/studio_floating_actions.dart';\nimport 'package:otraku/feature/studio/studio_header.dart';\nimport 'package:otraku/feature/studio/studio_model.dart';\nimport 'package:otraku/feature/studio/studio_provider.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/paged_controller.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/text_rail.dart';\n\nclass StudioView extends ConsumerStatefulWidget {\n  const StudioView(this.id, this.name);\n\n  final int id;\n  final String? name;\n\n  @override\n  ConsumerState<StudioView> createState() => _StudioViewState();\n}\n\nclass _StudioViewState extends ConsumerState<StudioView> {\n  late final _scrollCtrl = PagedController(\n    loadMore: () {\n      ref.read(studioMediaProvider(widget.id).notifier).fetch();\n    },\n  );\n\n  @override\n  void dispose() {\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        ref.listen<AsyncValue>(\n          studioMediaProvider(widget.id),\n          (_, s) =>\n              s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())),\n        );\n\n        final studio = ref.watch(studioProvider(widget.id)).value;\n        final studioMedia = ref.watch(studioMediaProvider(widget.id));\n        final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n        final mediaQuery = MediaQuery.of(context);\n\n        final header = StudioHeader(\n          id: widget.id,\n          name: studio?.name ?? widget.name,\n          studio: studio,\n          toggleFavorite: () => ref.read(studioProvider(widget.id).notifier).toggleFavorite(),\n        );\n\n        final content = studioMedia.unwrapPrevious().when(\n          loading: () => CustomScrollView(\n            physics: Theming.bouncyPhysics,\n            slivers: [\n              header,\n              const SliverFillRemaining(child: Center(child: Loader())),\n            ],\n          ),\n          error: (_, _) => CustomScrollView(\n            physics: Theming.bouncyPhysics,\n            slivers: [\n              header,\n              const SliverFillRemaining(child: Center(child: Text('Failed to load studio'))),\n            ],\n          ),\n          data: (data) => CustomScrollView(\n            physics: Theming.bouncyPhysics,\n            controller: _scrollCtrl,\n            slivers: [\n              header,\n              MediaQuery(\n                data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)),\n                child: SliverRefreshControl(\n                  onRefresh: () {\n                    ref.invalidate(studioProvider(widget.id));\n                    ref.invalidate(studioMediaProvider(widget.id));\n                  },\n                ),\n              ),\n              SliverConstrainedView(sliver: _StudioMediaGrid(data.items, options.highContrast)),\n              SliverFooter(loading: data.hasNext),\n            ],\n          ),\n        );\n\n        return AdaptiveScaffold(\n          floatingAction: studio != null\n              ? HidingFloatingActionButton(\n                  key: const Key('filter'),\n                  scrollCtrl: _scrollCtrl,\n                  child: StudioFilterButton(widget.id, ref),\n                )\n              : null,\n          child: content,\n        );\n      },\n    );\n  }\n}\n\nclass _StudioMediaGrid extends StatelessWidget {\n  const _StudioMediaGrid(this.items, this.highContrast);\n\n  final List<StudioMedia> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final textTheme = TextTheme.of(context);\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);\n    final labelSmallLineHeight = context.lineHeight(textTheme.labelSmall!);\n    final tileHeight =\n        bodyMediumLineHeight * 2 + labelMediumLineHeight + max(labelSmallLineHeight, 15) + 20;\n    final coverWidth = tileHeight / Theming.coverHtoWRatio;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 260, height: tileHeight),\n      delegate: SliverChildBuilderDelegate(\n        childCount: items.length,\n        (context, i) => _MediaTile(items[i], highContrast, coverWidth),\n      ),\n    );\n  }\n}\n\nclass _MediaTile extends StatelessWidget {\n  const _MediaTile(this.item, this.highContrast, this.coverWidth);\n\n  final StudioMedia item;\n  final bool highContrast;\n  final double coverWidth;\n\n  @override\n  Widget build(BuildContext context) {\n    final theme = Theme.of(context);\n\n    final textRailItems = <String, bool>{\n      if (item.format != null) item.format!.label: false,\n      if (item.entryStatus != null) item.entryStatus!.label(true): true,\n      if (item.releaseStatus != null) item.releaseStatus!.label: false,\n    };\n\n    return MediaRouteTile(\n      id: item.id,\n      imageUrl: item.cover,\n      child: CardExtension.highContrast(highContrast)(\n        child: Row(\n          mainAxisAlignment: .start,\n          children: [\n            Hero(\n              tag: item.id,\n              child: ClipRRect(\n                borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n                child: DecoratedBox(\n                  decoration: BoxDecoration(color: theme.colorScheme.surfaceContainerHighest),\n                  child: CachedImage(item.cover, width: coverWidth),\n                ),\n              ),\n            ),\n            Expanded(\n              child: Padding(\n                padding: .symmetric(horizontal: Theming.offset, vertical: 5),\n                child: Column(\n                  crossAxisAlignment: .start,\n                  mainAxisAlignment: .spaceEvenly,\n                  spacing: 5,\n                  children: [\n                    Flexible(child: Text(item.title, overflow: .ellipsis, maxLines: 2)),\n                    TextRail(textRailItems, style: theme.textTheme.labelMedium),\n                    if (item.startDate != null)\n                      Row(\n                        children: [\n                          Expanded(\n                            child: Text(\n                              item.startDate!,\n                              style: theme.textTheme.labelSmall!.copyWith(\n                                color: theme.colorScheme.primary,\n                              ),\n                            ),\n                          ),\n                          Expanded(\n                            child: Row(\n                              mainAxisSize: .min,\n                              spacing: 5,\n                              children: [\n                                Icon(\n                                  Icons.percent_rounded,\n                                  size: 15,\n                                  color: theme.colorScheme.onSurfaceVariant,\n                                ),\n                                Text(\n                                  item.weightedAverageScore.toString(),\n                                  style: theme.textTheme.labelSmall,\n                                ),\n                              ],\n                            ),\n                          ),\n                        ],\n                      ),\n                  ],\n                ),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/tag/tag_model.dart",
    "content": "import 'dart:collection';\n\nimport 'package:otraku/extension/iterable_extension.dart';\n\nclass Tag {\n  final String name;\n  final String desciption;\n  final bool isSpoiler;\n  final int? rank;\n\n  Tag._({\n    required this.name,\n    required this.rank,\n    required this.desciption,\n    required this.isSpoiler,\n  });\n\n  factory Tag(Map<String, dynamic> map) => Tag._(\n    name: map['name'],\n    rank: map['rank'],\n    desciption: map['description'] ?? 'No description',\n    isSpoiler: map['isMediaSpoiler'] ?? false,\n  );\n}\n\n/// Stores all tags (genres as treated as tags too).\nclass TagCollection {\n  TagCollection._({\n    required this.categories,\n    required this.ids,\n    required this.names,\n    required this.descriptions,\n    required this.indexByName,\n  });\n\n  factory TagCollection(Map<String, dynamic> map) {\n    final categories = [(name: _genreCategoryName, indexes: <int>[])];\n    final ids = <int>[];\n    final names = <String>[];\n    final descriptions = <String>[];\n    final indexByName = HashMap<String, int>();\n\n    /// Genres are given negative ids, as\n    /// to not get mixed up with normal tags.\n    int id = -1;\n    for (final g in map['GenreCollection']) {\n      categories[0].indexes.add(ids.length);\n      ids.add(id);\n      names.add(g.toString());\n      descriptions.add('');\n\n      indexByName.putIfAbsent(names.last, () => names.length - 1);\n      id--;\n    }\n\n    for (final t in map['MediaTagCollection']) {\n      String categoryName = t['category'] != null\n          ? (t['category'] as String).replaceFirst('-', '/')\n          : 'Other';\n      if (categoryName.isEmpty) categoryName = 'Other';\n\n      var category = categories.firstWhereOrNull((c) => c.name == categoryName);\n\n      if (category == null) {\n        category = (name: categoryName, indexes: []);\n        categories.add(category);\n      }\n\n      category.indexes.add(ids.length);\n      ids.add(t['id']);\n      names.add(t['name']);\n      descriptions.add(t['description'] ?? '');\n\n      indexByName.putIfAbsent(names.last, () => names.length - 1);\n    }\n\n    // Sort categories alphabetically.\n    // Genres must be at the front, while the adult category must be last.\n    categories.sort((a, b) {\n      if (a.name == _genreCategoryName) return -1;\n      if (a.name == _adultCategoryName) return 1;\n      if (b.name == _genreCategoryName) return 1;\n      if (b.name == _adultCategoryName) return -1;\n      return a.name.compareTo(b.name);\n    });\n\n    return TagCollection._(\n      categories: categories,\n      ids: ids,\n      names: names,\n      descriptions: descriptions,\n      indexByName: indexByName,\n    );\n  }\n\n  static const _genreCategoryName = 'Genres';\n  static const _adultCategoryName = 'Sexual Content';\n\n  /// Each category has a name and a list of indices for its tags.\n  final List<({String name, List<int> indexes})> categories;\n\n  /// Tag data.\n  final List<int> ids;\n  final List<String> names;\n  final List<String> descriptions;\n\n  final HashMap<String, int> indexByName;\n}\n"
  },
  {
    "path": "lib/feature/tag/tag_picker.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/iterable_extension.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/input/search_field.dart';\nimport 'package:otraku/widget/input/stateful_tiles.dart';\nimport 'package:otraku/widget/grid/chip_grid.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/widget/shadowed_overflow_list.dart';\nimport 'package:otraku/feature/tag/tag_model.dart';\nimport 'package:otraku/feature/tag/tag_provider.dart';\n\nclass TagPicker extends StatefulWidget {\n  const TagPicker({\n    required this.includedGenres,\n    required this.excludedGenres,\n    required this.includedTags,\n    required this.excludedTags,\n  });\n\n  final List<String> includedGenres;\n  final List<String> excludedGenres;\n  final List<String> includedTags;\n  final List<String> excludedTags;\n\n  @override\n  TagPickerState createState() => TagPickerState();\n}\n\nclass TagPickerState extends State<TagPicker> {\n  @override\n  Widget build(BuildContext context) {\n    final children = <Widget>[];\n\n    for (final name in widget.includedGenres) {\n      children.add(\n        _DualStateTagChip(\n          key: Key(name),\n          label: name,\n          positive: true,\n          onChanged: (positive) => _toggleGenre(name, positive),\n          onRemoved: () => setState(() => widget.includedGenres.remove(name)),\n        ),\n      );\n    }\n\n    for (final name in widget.excludedGenres) {\n      children.add(\n        _DualStateTagChip(\n          key: Key(name),\n          label: name,\n          positive: false,\n          onChanged: (positive) => _toggleGenre(name, positive),\n          onRemoved: () => setState(() => widget.excludedGenres.remove(name)),\n        ),\n      );\n    }\n\n    for (final name in widget.includedTags) {\n      children.add(\n        _DualStateTagChip(\n          key: Key(name),\n          label: name,\n          positive: true,\n          onChanged: (positive) => _toggleTag(name, positive),\n          onRemoved: () => setState(() => widget.includedTags.remove(name)),\n        ),\n      );\n    }\n\n    for (final name in widget.excludedTags) {\n      children.add(\n        _DualStateTagChip(\n          key: Key(name),\n          label: name,\n          positive: false,\n          onChanged: (positive) => _toggleTag(name, positive),\n          onRemoved: () => setState(() => widget.excludedTags.remove(name)),\n        ),\n      );\n    }\n\n    return ChipGrid(\n      title: 'Tags',\n      placeholder: 'tags',\n      children: children,\n      onEdit: () => showSheet(\n        context,\n        SimpleSheet(\n          builder: (context, scrollCtrl) => Consumer(\n            builder: (context, ref, child) {\n              TagCollection tags;\n              switch (ref.watch(tagsProvider)) {\n                case AsyncData(:final value):\n                  tags = value;\n                  break;\n                case AsyncError(:final error):\n                  return Center(\n                    child: Padding(\n                      padding: Theming.paddingAll,\n                      child: Text('Failed to load tags: ${error.toString()}'),\n                    ),\n                  );\n                case AsyncLoading():\n                  return const Center(child: Loader());\n              }\n\n              return _FilterTagSheet(\n                tags: tags,\n                includedGenres: widget.includedGenres,\n                excludedGenres: widget.excludedGenres,\n                includedTags: widget.includedTags,\n                excludedTags: widget.excludedTags,\n                scrollCtrl: scrollCtrl,\n              );\n            },\n          ),\n        ),\n      ).then((_) => setState(() {})),\n      onClear: () => setState(() {\n        widget.includedGenres.clear();\n        widget.excludedGenres.clear();\n        widget.includedTags.clear();\n        widget.excludedTags.clear();\n      }),\n    );\n  }\n\n  void _toggleGenre(String name, bool positive) {\n    if (positive) {\n      widget.includedGenres.add(name);\n      widget.excludedGenres.remove(name);\n    } else {\n      widget.excludedGenres.add(name);\n      widget.includedGenres.remove(name);\n    }\n  }\n\n  void _toggleTag(String name, bool positive) {\n    if (positive) {\n      widget.includedTags.add(name);\n      widget.excludedTags.remove(name);\n    } else {\n      widget.excludedTags.add(name);\n      widget.includedTags.remove(name);\n    }\n  }\n}\n\nclass _DualStateTagChip extends StatefulWidget {\n  const _DualStateTagChip({\n    required super.key,\n    required this.label,\n    required this.positive,\n    required this.onChanged,\n    required this.onRemoved,\n  });\n\n  final String label;\n  final bool positive;\n  final void Function(bool) onChanged;\n  final void Function() onRemoved;\n\n  @override\n  State<_DualStateTagChip> createState() => _DualStateTagChipState();\n}\n\nclass _DualStateTagChipState extends State<_DualStateTagChip> {\n  late bool _positive = widget.positive;\n\n  @override\n  Widget build(BuildContext context) {\n    return InputChip(\n      label: Text(widget.label),\n      labelStyle: TextStyle(\n        color: _positive\n            ? ColorScheme.of(context).onSecondaryContainer\n            : ColorScheme.of(context).onErrorContainer,\n      ),\n      deleteIconColor: _positive\n          ? ColorScheme.of(context).onSecondaryContainer\n          : ColorScheme.of(context).onErrorContainer,\n      backgroundColor: _positive\n          ? ColorScheme.of(context).secondaryContainer\n          : ColorScheme.of(context).errorContainer,\n      onDeleted: widget.onRemoved,\n      onPressed: () {\n        setState(() => _positive = !_positive);\n        widget.onChanged(_positive);\n      },\n    );\n  }\n}\n\nclass _FilterTagSheet extends ConsumerStatefulWidget {\n  const _FilterTagSheet({\n    required this.tags,\n    required this.includedGenres,\n    required this.excludedGenres,\n    required this.includedTags,\n    required this.excludedTags,\n    required this.scrollCtrl,\n  });\n\n  final TagCollection tags;\n  final List<String> includedGenres;\n  final List<String> excludedGenres;\n  final List<String> includedTags;\n  final List<String> excludedTags;\n  final ScrollController scrollCtrl;\n\n  @override\n  ConsumerState<_FilterTagSheet> createState() => _FilterTagSheetState();\n}\n\nclass _FilterTagSheetState extends ConsumerState<_FilterTagSheet> {\n  late final List<int> _itemIndexes;\n  late final List<int> _categoryIndexes;\n  String _filter = '';\n  int _index = 0;\n\n  @override\n  void initState() {\n    super.initState();\n    _itemIndexes = [...widget.tags.categories[_index].indexes];\n    _categoryIndexes = List.generate(widget.tags.categories.length, (i) => i);\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    late final List<String> included;\n    late final List<String> excluded;\n    if (_categoryIndexes.isNotEmpty && _categoryIndexes[_index] == 0) {\n      included = widget.includedGenres;\n      excluded = widget.excludedGenres;\n    } else {\n      included = widget.includedTags;\n      excluded = widget.excludedTags;\n    }\n\n    return Stack(\n      children: [\n        if (_itemIndexes.isNotEmpty)\n          Material(\n            color: Colors.transparent,\n            child: ListView.builder(\n              padding: .only(top: 110, bottom: MediaQuery.paddingOf(context).bottom),\n              controller: widget.scrollCtrl,\n              itemCount: _itemIndexes.length,\n              itemExtent: 56,\n              itemBuilder: (_, i) {\n                final name = widget.tags.names[_itemIndexes[i]];\n                return StatefulCheckboxListTile(\n                  title: Text(name),\n                  tristate: true,\n                  value: included.contains(name)\n                      ? true\n                      : excluded.contains(name)\n                      ? null\n                      : false,\n                  onChanged: (v) {\n                    if (v == null) {\n                      included.remove(name);\n                      excluded.add(name);\n                    } else if (v) {\n                      included.add(name);\n                    } else {\n                      excluded.remove(name);\n                    }\n                  },\n                );\n              },\n            ),\n          )\n        else\n          const Center(child: Text('No Results')),\n        ClipRRect(\n          borderRadius: const BorderRadius.vertical(top: Theming.radiusBig),\n          child: BackdropFilter(\n            filter: Theming.blurFilter,\n            child: Container(\n              height: 110,\n              color: Theme.of(context).navigationBarTheme.backgroundColor,\n              padding: const .symmetric(vertical: Theming.offset),\n              child: Column(\n                children: [\n                  Padding(\n                    padding: const .only(\n                      left: Theming.offset,\n                      right: Theming.offset,\n                      bottom: Theming.offset,\n                    ),\n                    child: SearchField(hint: 'Tag', value: _filter, onChanged: _onSearch),\n                  ),\n                  SizedBox(\n                    height: 40,\n                    child: ShadowedOverflowList(\n                      itemCount: _categoryIndexes.length,\n                      itemBuilder: _categoryChipBuilder,\n                    ),\n                  ),\n                ],\n              ),\n            ),\n          ),\n        ),\n      ],\n    );\n  }\n\n  void _onSearch(String val) {\n    final tags = widget.tags;\n    _filter = val.toLowerCase();\n    _categoryIndexes.clear();\n    _itemIndexes.clear();\n\n    for (int i = 0; i < tags.categories.length; i++) {\n      final matchingTag = tags.categories[i].indexes.firstWhereOrNull(\n        (index) => tags.names[index].toLowerCase().contains(_filter),\n      );\n\n      if (matchingTag != null) {\n        _categoryIndexes.add(i);\n      }\n    }\n\n    if (_categoryIndexes.isEmpty) {\n      _index = 0;\n      setState(() {});\n      return;\n    }\n\n    if (_index >= _categoryIndexes.length) {\n      _index = _categoryIndexes.length - 1;\n    }\n\n    for (final i in tags.categories[_categoryIndexes[_index]].indexes) {\n      if (tags.names[i].toLowerCase().contains(_filter)) {\n        _itemIndexes.add(i);\n      }\n    }\n\n    setState(() {});\n  }\n\n  Widget _categoryChipBuilder(BuildContext context, int i) {\n    final tags = widget.tags;\n\n    return _TagCategoryChip(\n      name: tags.categories[_categoryIndexes[i]].name,\n      selected: i == _index,\n      onTap: () {\n        if (_index == i) return;\n\n        _index = i;\n        _itemIndexes.clear();\n\n        for (final i in tags.categories[_categoryIndexes[_index]].indexes) {\n          if (tags.names[i].toLowerCase().contains(_filter)) {\n            _itemIndexes.add(i);\n          }\n        }\n\n        setState(() {});\n      },\n    );\n  }\n}\n\nclass _TagCategoryChip extends StatelessWidget {\n  const _TagCategoryChip({required this.name, required this.selected, required this.onTap});\n\n  final String name;\n  final bool selected;\n  final void Function() onTap;\n\n  @override\n  Widget build(BuildContext context) {\n    return GestureDetector(\n      onTap: onTap,\n      child: Chip(\n        label: Text(name),\n        labelStyle: selected\n            ? TextTheme.of(context).bodyMedium?.copyWith(color: ColorScheme.of(context).surface)\n            : TextTheme.of(context).bodyMedium,\n        backgroundColor: selected\n            ? ColorScheme.of(context).primary\n            : ColorScheme.of(context).onSecondary,\n        side: selected\n            ? BorderSide(color: ColorScheme.of(context).primary)\n            : BorderSide(color: ColorScheme.of(context).onSurface),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/tag/tag_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/util/graphql.dart';\nimport 'package:otraku/feature/tag/tag_model.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\n\nfinal tagsProvider = FutureProvider(\n  (ref) async => TagCollection(await ref.read(repositoryProvider).request(GqlQuery.genresAndTags)),\n);\n"
  },
  {
    "path": "lib/feature/thread/thread_model.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/feature/comment/comment_model.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/util/markdown.dart';\n\nclass Thread {\n  const Thread._({\n    required this.info,\n    required this.comments,\n    required this.commentPage,\n    required this.totalCommentPages,\n  });\n\n  factory Thread(Map<String, dynamic> map, ImageQuality imageQuality) =>\n      Thread._withInfo(ThreadInfo(map['Thread'], imageQuality), map);\n\n  factory Thread._withInfo(ThreadInfo info, Map<String, dynamic> map) {\n    final comments = <Comment>[];\n    for (final c in map['Page']?['threadComments'] ?? const []) {\n      comments.add(Comment(c));\n    }\n\n    return Thread._(\n      info: info,\n      comments: comments,\n      commentPage: map['Page']?['pageInfo']?['currentPage'] ?? 1,\n      totalCommentPages: map['Page']?['pageInfo']?['lastPage'] ?? 1,\n    );\n  }\n\n  final ThreadInfo info;\n  final List<Comment> comments;\n  final int commentPage;\n  final int totalCommentPages;\n\n  Thread withChangingCommentPage(int commentPage) => Thread._(\n    info: info,\n    comments: comments,\n    commentPage: commentPage,\n    totalCommentPages: totalCommentPages,\n  );\n\n  Thread withChangedCommentPage(Map<String, dynamic> map) => Thread._withInfo(info, map);\n\n  Thread withAppendedComment(Map<String, dynamic> map, int? parentCommentId) {\n    if (parentCommentId == null) {\n      return Thread._(\n        info: info,\n        commentPage: commentPage,\n        totalCommentPages: totalCommentPages,\n        comments: [...comments, Comment(map)],\n      );\n    }\n\n    for (final comment in comments) {\n      if (comment.append(map, parentCommentId)) {\n        return Thread._(\n          info: info,\n          commentPage: commentPage,\n          totalCommentPages: totalCommentPages,\n          comments: [...comments],\n        );\n      }\n    }\n\n    return this;\n  }\n}\n\nclass ThreadInfo {\n  ThreadInfo._({\n    required this.id,\n    required this.title,\n    required this.body,\n    required this.viewCount,\n    required this.replyCount,\n    required this.likeCount,\n    required this.isLiked,\n    required this.isSubscribed,\n    required this.isPinned,\n    required this.isLocked,\n    required this.siteUrl,\n    required this.createdAt,\n    required this.categories,\n    required this.media,\n    required this.userId,\n    required this.userName,\n    required this.userAvatarUrl,\n  });\n\n  factory ThreadInfo(Map<String, dynamic> map, ImageQuality imageQuality) {\n    final categories = <String>[];\n    for (final c in map['categories'] ?? const []) {\n      categories.add(c['name']);\n    }\n\n    final media = <ThreadMedia>[];\n    for (final m in map['mediaCategories'] ?? const []) {\n      media.add((\n        id: m['id'] ?? 0,\n        title: m['title']?['userPreferred'] ?? '',\n        coverUrl: m['coverImage']?[imageQuality.value] ?? '',\n      ));\n    }\n\n    return ThreadInfo._(\n      id: map['id'],\n      title: map['title'] ?? '?',\n      body: parseMarkdown(map['body'] ?? ''),\n      viewCount: map['viewCount'] ?? 0,\n      replyCount: map['replyCount'] ?? 0,\n      likeCount: map['likeCount'] ?? 0,\n      isLiked: map['isLiked'] ?? false,\n      isLocked: map['isLocked'] ?? false,\n      isSubscribed: map['isSubscribed'] ?? false,\n      isPinned: map['isSticky'] ?? false,\n      siteUrl: map['siteUrl'] ?? '',\n      createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),\n      categories: categories,\n      media: media,\n      userId: map['user']?['id'] ?? 0,\n      userName: map['user']?['name'] ?? '?',\n      userAvatarUrl: map['user']?['avatar']?['large'] ?? '',\n    );\n  }\n\n  final int id;\n  final String title;\n  final String body;\n  final int viewCount;\n  int likeCount;\n  final int replyCount;\n  bool isLiked;\n  bool isSubscribed;\n  final bool isPinned;\n  final bool isLocked;\n  final String siteUrl;\n  final DateTime createdAt;\n  final List<String> categories;\n  final List<ThreadMedia> media;\n  final int userId;\n  final String userName;\n  final String userAvatarUrl;\n}\n\ntypedef ThreadMedia = ({int id, String title, String coverUrl});\n"
  },
  {
    "path": "lib/feature/thread/thread_provider.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/feature/thread/thread_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\nfinal threadProvider = AsyncNotifierProvider.autoDispose.family<ThreadNotifier, Thread, int>(\n  ThreadNotifier.new,\n);\n\nclass ThreadNotifier extends AsyncNotifier<Thread> {\n  ThreadNotifier(this.arg);\n\n  final int arg;\n\n  @override\n  FutureOr<Thread> build() async {\n    final data = await ref.read(repositoryProvider).request(GqlQuery.thread, {\n      'id': arg,\n      'withInfo': true,\n    });\n\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n\n    return Thread(data, options.imageQuality);\n  }\n\n  Future<void> changePage(int page) async {\n    final value = state.value;\n    if (value == null) return;\n\n    state = state.whenData((data) => data.withChangingCommentPage(page));\n    state = const AsyncValue.loading();\n\n    final data = await ref.read(repositoryProvider).request(GqlQuery.thread, {\n      'id': arg,\n      'page': page,\n    });\n\n    state = AsyncValue.data(value.withChangedCommentPage(data));\n  }\n\n  void appendComment(Map<String, dynamic> map, int? parentCommentId) {\n    final value = state.value;\n    if (value == null) return;\n\n    // If there's a new thread comment, it can only appear on the last page.\n    if (parentCommentId == null && value.commentPage != value.totalCommentPages) {\n      return;\n    }\n\n    state = AsyncValue.data(value.withAppendedComment(map, parentCommentId));\n  }\n\n  Future<Object?> toggleThreadLike() {\n    final value = state.value;\n    if (value == null) return Future.value(null);\n\n    return ref.read(repositoryProvider).request(GqlMutation.toggleLike, {\n      'id': value.info.id,\n      'type': 'THREAD',\n    }).getErrorOrNull();\n  }\n\n  Future<Object?> toggleCommentLike(int commentId) {\n    return ref.read(repositoryProvider).request(GqlMutation.toggleLike, {\n      'id': commentId,\n      'type': 'THREAD_COMMENT',\n    }).getErrorOrNull();\n  }\n\n  Future<Object?> toggleThreadSubscription() async {\n    final value = state.value;\n    if (value == null) return null;\n\n    final info = value.info;\n    final prevIsSubscribed = info.isSubscribed;\n    info.isSubscribed = !prevIsSubscribed;\n\n    final err = await ref.read(repositoryProvider).request(GqlMutation.toggleThreadSubscription, {\n      'id': info.id,\n      'subscribe': info.isSubscribed,\n    }).getErrorOrNull();\n\n    if (err != null) {\n      info.isSubscribed = prevIsSubscribed;\n      return err;\n    }\n\n    return null;\n  }\n\n  Future<Object?> delete() =>\n      ref.read(repositoryProvider).request(GqlMutation.deleteThread, {'id': arg}).getErrorOrNull();\n}\n"
  },
  {
    "path": "lib/feature/thread/thread_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/feature/composition/composition_model.dart';\nimport 'package:otraku/feature/composition/composition_view.dart';\nimport 'package:otraku/feature/comment/comment_tile.dart';\nimport 'package:otraku/feature/forum/forum_filter_model.dart';\nimport 'package:otraku/feature/forum/forum_filter_provider.dart';\nimport 'package:otraku/feature/thread/thread_model.dart';\nimport 'package:otraku/feature/thread/thread_provider.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/navigation_tool.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/shadowed_overflow_list.dart';\nimport 'package:otraku/widget/sheets.dart';\nimport 'package:otraku/widget/timestamp.dart';\n\nclass ThreadView extends ConsumerStatefulWidget {\n  const ThreadView(this.id);\n\n  final int id;\n\n  @override\n  ConsumerState<ThreadView> createState() => _ThreadViewState();\n}\n\nclass _ThreadViewState extends ConsumerState<ThreadView> {\n  final _scrollCtrl = ScrollController();\n\n  @override\n  void dispose() {\n    _scrollCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    ref.listen<AsyncValue>(\n      threadProvider(widget.id),\n      (_, s) =>\n          s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())),\n    );\n\n    final thread = ref.watch(threadProvider(widget.id));\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n    final viewerId = ref.watch(viewerIdProvider);\n\n    return AdaptiveScaffold(\n      topBar: TopBar(\n        trailing: thread.hasValue\n            ? _topBarTrailingContent(thread.value!, viewerId)\n            : const <Widget>[],\n      ),\n      floatingAction: HidingFloatingActionButton(\n        key: const Key('Reply'),\n        scrollCtrl: _scrollCtrl,\n        child: FloatingActionButton(\n          tooltip: 'New Reply',\n          child: const Icon(Icons.edit_outlined),\n          onPressed: () => showSheet(\n            context,\n            CompositionView(\n              tag: CommentCompositionTag(threadId: widget.id, parentCommentId: null),\n              onSaved: (map) =>\n                  ref.read(threadProvider(widget.id).notifier).appendComment(map, null),\n            ),\n          ),\n        ),\n      ),\n      bottomBar: thread.hasValue && thread.value!.totalCommentPages > 1\n          ? _BottomBar(\n              thread: thread.value!,\n              changePage: (page) => ref.read(threadProvider(widget.id).notifier).changePage(page),\n            )\n          : null,\n      child: ConstrainedView(\n        child: switch (thread.unwrapPrevious()) {\n          AsyncData(:final value) => _Content(\n            ref,\n            value,\n            options.highContrast,\n            options.analogClock,\n            _scrollCtrl,\n          ),\n          AsyncError() => CustomScrollView(\n            physics: Theming.bouncyPhysics,\n            slivers: [\n              SliverRefreshControl(onRefresh: () => ref.invalidate(threadProvider(widget.id))),\n              const SliverFillRemaining(child: Center(child: Text('Failed to load'))),\n            ],\n          ),\n          AsyncLoading() => const Center(child: Loader()),\n        },\n      ),\n    );\n  }\n\n  List<Widget> _topBarTrailingContent(Thread thread, int? viewerId) => [\n    Expanded(\n      child: GestureDetector(\n        behavior: .opaque,\n        onTap: () => context.push(Routes.user(thread.info.userId, thread.info.userAvatarUrl)),\n        child: Row(\n          mainAxisSize: .min,\n          children: [\n            Hero(\n              tag: thread.info.userId,\n              child: ClipRRect(\n                borderRadius: Theming.borderRadiusSmall,\n                child: CachedImage(thread.info.userAvatarUrl, height: 40, width: 40),\n              ),\n            ),\n            const SizedBox(width: Theming.offset),\n            Flexible(child: Text(thread.info.userName, overflow: .ellipsis, maxLines: 1)),\n          ],\n        ),\n      ),\n    ),\n    IconButton(\n      tooltip: 'More',\n      icon: const Icon(Ionicons.ellipsis_horizontal),\n      onPressed: () => showSheet(\n        context,\n        SimpleSheet.link(context, thread.info.siteUrl, [\n          ListTile(\n            title: !thread.info.isSubscribed ? const Text('Subscribe') : const Text('Unsubscribe'),\n            leading: !thread.info.isSubscribed\n                ? const Icon(Ionicons.notifications_outline)\n                : const Icon(Ionicons.notifications_off_outline),\n            onTap: _toggleSubscription,\n          ),\n          if (viewerId == thread.info.userId)\n            ListTile(\n              title: const Text('Delete'),\n              leading: const Icon(Ionicons.trash_outline),\n              onTap: () {\n                Navigator.pop(context);\n\n                ConfirmationDialog.show(\n                  context,\n                  title: 'Delete?',\n                  primaryAction: 'Yes',\n                  secondaryAction: 'No',\n                  onConfirm: _delete,\n                );\n              },\n            ),\n        ]),\n      ),\n    ),\n  ];\n\n  void _toggleSubscription() async {\n    final err = await ref.read(threadProvider(widget.id).notifier).toggleThreadSubscription();\n\n    if (!mounted) return;\n\n    if (err == null) {\n      Navigator.pop(context);\n      return;\n    }\n\n    SnackBarExtension.show(context, err.toString());\n    Navigator.pop(context);\n  }\n\n  void _delete() async {\n    final err = await ref.read(threadProvider(widget.id).notifier).delete();\n\n    if (!mounted) return;\n\n    if (err == null) {\n      Navigator.pop(context);\n      return;\n    }\n\n    SnackBarExtension.show(context, 'Failed deleting thread: $err');\n  }\n}\n\nclass _BottomBar extends StatefulWidget {\n  const _BottomBar({required this.thread, required this.changePage});\n\n  final Thread thread;\n  final void Function(int page) changePage;\n\n  @override\n  State<_BottomBar> createState() => __BottomBarState();\n}\n\nclass __BottomBarState extends State<_BottomBar> {\n  late var _value = widget.thread.commentPage;\n\n  @override\n  void didUpdateWidget(covariant _BottomBar oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _value = widget.thread.commentPage;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final thread = widget.thread;\n\n    final currentPageLabel = Text('$_value');\n    final previousPageButton = IconButton(\n      tooltip: 'Previous page',\n      icon: const Icon(Icons.arrow_back_ios_rounded),\n      onPressed: thread.commentPage == 1 ? null : () => widget.changePage(thread.commentPage - 1),\n    );\n    final nextPageButton = IconButton(\n      tooltip: 'Next page',\n      icon: const Icon(Icons.arrow_forward_ios_rounded),\n      onPressed: thread.commentPage == thread.totalCommentPages\n          ? null\n          : () => widget.changePage(thread.commentPage + 1),\n    );\n    final pageSlider = Expanded(\n      child: Slider.adaptive(\n        min: 1,\n        max: thread.totalCommentPages.toDouble(),\n        value: _value.toDouble(),\n        onChanged: (value) => setState(() => _value = value.round()),\n        onChangeEnd: (value) => widget.changePage(value.round()),\n      ),\n    );\n\n    final bottomBarItems = Theming.of(context).rightButtonOrientation\n        ? [pageSlider, previousPageButton, currentPageLabel, nextPageButton]\n        : [previousPageButton, currentPageLabel, nextPageButton, pageSlider];\n\n    return BottomBar(bottomBarItems);\n  }\n}\n\nclass _Content extends StatelessWidget {\n  const _Content(this.ref, this.thread, this.highContrast, this.analogClock, this.scrollCtrl);\n\n  final WidgetRef ref;\n  final Thread thread;\n  final bool highContrast;\n  final bool analogClock;\n  final ScrollController scrollCtrl;\n\n  @override\n  Widget build(BuildContext context) {\n    final viewerId = ref.watch(viewerIdProvider);\n    const spacing = SliverToBoxAdapter(child: SizedBox(height: Theming.offset));\n    final info = thread.info;\n\n    return CustomScrollView(\n      controller: scrollCtrl,\n      physics: Theming.bouncyPhysics,\n      slivers: [\n        SliverRefreshControl(onRefresh: () => ref.invalidate(threadProvider(thread.info.id))),\n        SliverToBoxAdapter(child: Timestamp(info.createdAt, analogClock)),\n        spacing,\n        SliverToBoxAdapter(child: Text(thread.info.title, style: TextTheme.of(context).bodyMedium)),\n        spacing,\n        HtmlContent(thread.info.body, renderMode: RenderMode.sliverList),\n        spacing,\n        if (info.media.isNotEmpty)\n          SliverToBoxAdapter(\n            child: SizedBox(\n              height: Theming.minTapTarget,\n              child: ShadowedOverflowList(\n                itemCount: info.media.length,\n                itemBuilder: (context, i) {\n                  final media = info.media[i];\n\n                  return ActionChip(\n                    label: Text(media.title),\n                    avatar: CachedImage(media.coverUrl),\n                    onPressed: () => context.push(Routes.media(media.id)),\n                  );\n                },\n              ),\n            ),\n          ),\n        SliverToBoxAdapter(\n          child: SizedBox(\n            height: Theming.minTapTarget,\n            child: ShadowedOverflowList(\n              itemCount: info.categories.length,\n              itemBuilder: (context, i) {\n                final label = info.categories[i];\n\n                return ActionChip(\n                  label: Text(label),\n                  onPressed: () {\n                    context.push(Routes.forum);\n\n                    ref.invalidate(forumFilterProvider);\n                    ref\n                        .read(forumFilterProvider.notifier)\n                        .update(\n                          (filter) => filter.copyWith(category: (ThreadCategory.from(label),)),\n                        );\n                  },\n                );\n              },\n            ),\n          ),\n        ),\n        SliverToBoxAdapter(\n          child: Padding(\n            padding: const .symmetric(vertical: Theming.offset),\n            child: Row(\n              spacing: Theming.offset,\n              children: [\n                if (info.isPinned)\n                  Tooltip(\n                    message: 'Pinned',\n                    triggerMode: .tap,\n                    child: Icon(Icons.push_pin_outlined, size: Theming.iconSmall),\n                  ),\n                if (info.isLocked)\n                  Tooltip(\n                    message: 'Locked',\n                    triggerMode: .tap,\n                    child: Icon(Icons.lock_outline_rounded, size: Theming.iconSmall),\n                  ),\n                const Spacer(),\n                Tooltip(\n                  message: 'Views',\n                  triggerMode: .tap,\n                  child: Row(\n                    mainAxisSize: .min,\n                    children: [\n                      Text(\n                        info.viewCount.toString(),\n                        style: Theme.of(context).textTheme.labelSmall,\n                      ),\n                      const SizedBox(width: 5),\n                      Icon(Icons.remove_red_eye_outlined, size: Theming.iconSmall),\n                    ],\n                  ),\n                ),\n                Tooltip(\n                  message: 'Replies',\n                  triggerMode: .tap,\n                  child: Row(\n                    mainAxisSize: .min,\n                    spacing: 5,\n                    children: [\n                      Text(\n                        info.replyCount.toString(),\n                        style: Theme.of(context).textTheme.labelSmall,\n                      ),\n                      Icon(Icons.reply_all_rounded, size: Theming.iconSmall),\n                    ],\n                  ),\n                ),\n                _LikeButton(ref, info),\n              ],\n            ),\n          ),\n        ),\n        spacing,\n        SliverList.builder(\n          itemCount: thread.comments.length,\n          itemBuilder: (context, i) {\n            final comment = thread.comments[i];\n\n            return Padding(\n              padding: const .only(bottom: Theming.offset),\n              child: CommentTile(\n                comment,\n                viewerId: viewerId,\n                highContrast: highContrast,\n                analogClock: analogClock,\n                interaction: (\n                  onReplySaved: (map, commentId) =>\n                      ref.read(threadProvider(info.id).notifier).appendComment(map, commentId),\n                  toggleLike: (commentId) =>\n                      ref.read(threadProvider(info.id).notifier).toggleCommentLike(commentId),\n                ),\n              ),\n            );\n          },\n        ),\n        const SliverFooter(),\n      ],\n    );\n  }\n}\n\nclass _LikeButton extends StatefulWidget {\n  const _LikeButton(this.ref, this.threadInfo);\n\n  final WidgetRef ref;\n  final ThreadInfo threadInfo;\n\n  @override\n  State<_LikeButton> createState() => __LikeButtonState();\n}\n\nclass __LikeButtonState extends State<_LikeButton> {\n  @override\n  Widget build(BuildContext context) {\n    final info = widget.threadInfo;\n\n    return Tooltip(\n      message: !info.isLiked ? 'Like' : 'Unlike',\n      child: InkResponse(\n        radius: Theming.radiusSmall.x,\n        onTap: () async {\n          final prevIsLiked = info.isLiked;\n          final prevLikeCount = info.likeCount;\n\n          setState(() {\n            info.isLiked = !prevIsLiked;\n            info.likeCount = prevLikeCount + 1;\n          });\n\n          final err = await widget.ref.read(threadProvider(info.id).notifier).toggleThreadLike();\n\n          if (err == null) return;\n\n          setState(() {\n            info.isLiked = prevIsLiked;\n            info.likeCount = prevLikeCount;\n          });\n\n          if (context.mounted) {\n            SnackBarExtension.show(context, err.toString());\n          }\n        },\n        child: Row(\n          children: [\n            Text(\n              info.likeCount.toString(),\n              style: !info.isLiked\n                  ? TextTheme.of(context).labelSmall\n                  : TextTheme.of(\n                      context,\n                    ).labelSmall!.copyWith(color: ColorScheme.of(context).primary),\n            ),\n            const SizedBox(width: 5),\n            Icon(\n              !info.isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded,\n              size: Theming.iconSmall,\n              color: info.isLiked ? ColorScheme.of(context).primary : null,\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/user/user_header.dart",
    "content": "import 'dart:math';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/feature/user/user_model.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/input/pill_selector.dart';\nimport 'package:otraku/widget/layout/content_header.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/text_rail.dart';\n\nclass UserHeader extends StatelessWidget {\n  const UserHeader({\n    required this.id,\n    required this.isViewer,\n    required this.user,\n    required this.imageUrl,\n    required this.toggleFollow,\n  });\n\n  final int? id;\n  final bool isViewer;\n  final User? user;\n  final String? imageUrl;\n  final Future<Object?> Function() toggleFollow;\n\n  @override\n  Widget build(BuildContext context) {\n    final textRailItems = <String, bool>{};\n    if (user != null) {\n      if (user!.modRoles.isNotEmpty) textRailItems[user!.modRoles[0]] = false;\n      if (user!.donatorTier > 0) textRailItems[user!.donatorBadge] = true;\n    }\n\n    return ContentHeader(\n      imageUrl: user?.imageUrl ?? imageUrl,\n      imageHeightToWidthRatio: 1,\n      imageHeroTag: id ?? '',\n      imageFit: BoxFit.contain,\n      bannerUrl: user?.bannerUrl,\n      siteUrl: user?.siteUrl,\n      title: user?.name,\n      details: [\n        GestureDetector(\n          behavior: .opaque,\n          onTap: () {\n            if (user?.modRoles.isNotEmpty ?? false) {\n              showDialog(\n                context: context,\n                builder: (context) => TextDialog(title: 'Roles', text: user!.modRoles.join(', ')),\n              );\n            }\n          },\n          child: TextRail(textRailItems, style: TextTheme.of(context).labelMedium),\n        ),\n        if (user?.createdAt != null)\n          Text('Joined ${user!.createdAt!}', style: TextTheme.of(context).labelSmall),\n      ],\n      trailingTopButtons: [\n        if (isViewer) ...[\n          IconButton(\n            tooltip: 'Switch Account',\n            icon: const Icon(Icons.manage_accounts_outlined),\n            onPressed: () =>\n                showDialog(context: context, builder: (context) => const _AccountPicker()),\n          ),\n          IconButton(\n            tooltip: 'Settings',\n            icon: const Icon(Ionicons.cog_outline),\n            onPressed: () => context.push(Routes.settings),\n          ),\n        ] else if (user != null)\n          _FollowButton(user!, toggleFollow),\n      ],\n    );\n  }\n}\n\nclass _AccountPicker extends StatefulWidget {\n  const _AccountPicker();\n\n  @override\n  State<_AccountPicker> createState() => __AccountPickerState();\n}\n\nclass __AccountPickerState extends State<_AccountPicker> {\n  static const _loginLink =\n      'https://anilist.co/api/v2/oauth/authorize?client_id=3535&response_type=token';\n\n  static const _imageSize = 55.0;\n\n  @override\n  Widget build(BuildContext context) {\n    const divider = SizedBox(height: 40, child: VerticalDivider(width: 10, thickness: 1));\n\n    final bodyMediumTextHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final labelSmallTextHeight = context.lineHeight(TextTheme.of(context).labelSmall!);\n    final rowHeight = max(_imageSize, bodyMediumTextHeight + labelSmallTextHeight * 2) + 10;\n\n    return Dialog(\n      insetPadding: const .symmetric(vertical: 24, horizontal: Theming.offset),\n      shape: const RoundedRectangleBorder(\n        borderRadius: BorderRadiusGeometry.all(Radius.circular(32)),\n      ),\n      child: Consumer(\n        builder: (context, ref, _) {\n          final accountGroup = ref.watch(persistenceProvider.select((s) => s.accountGroup));\n          final accounts = accountGroup.accounts;\n\n          final items = <Widget>[\n            for (int i = 0; i < accounts.length; i++)\n              SizedBox(\n                height: rowHeight,\n                child: Row(\n                  children: [\n                    Padding(\n                      padding: .all(5),\n                      child: CachedImage(\n                        accounts[i].avatarUrl,\n                        width: _imageSize,\n                        height: _imageSize,\n                      ),\n                    ),\n                    Expanded(\n                      child: Column(\n                        mainAxisAlignment: .center,\n                        crossAxisAlignment: .start,\n                        children: [\n                          Text(\n                            '${accounts[i].name} ${accounts[i].id}',\n                            overflow: .ellipsis,\n                            maxLines: 1,\n                          ),\n                          Text(\n                            DateTime.now().isBefore(accounts[i].expiration)\n                                ? 'Expires in ${accounts[i].expiration.timeUntil}'\n                                : 'Expired',\n                            style: TextTheme.of(context).labelSmall,\n                            overflow: .ellipsis,\n                            maxLines: 2,\n                          ),\n                        ],\n                      ),\n                    ),\n                    divider,\n                    IconButton(\n                      tooltip: 'Remove Account',\n                      icon: const Icon(Icons.close_rounded),\n                      onPressed: () => ConfirmationDialog.show(\n                        context,\n                        title: 'Remove Account?',\n                        primaryAction: 'Yes',\n                        secondaryAction: 'No',\n                        onConfirm: () {\n                          if (i == accountGroup.accountIndex) {\n                            ref.read(persistenceProvider.notifier).switchAccount(null);\n                          }\n\n                          ref\n                              .read(persistenceProvider.notifier)\n                              .removeAccount(i)\n                              .then((_) => setState(() {}));\n                        },\n                      ),\n                    ),\n                  ],\n                ),\n              ),\n          ];\n\n          items.add(\n            SizedBox(\n              height: rowHeight,\n              child: Row(\n                children: [\n                  const Padding(\n                    padding: .all(5),\n                    child: Icon(Icons.person_rounded, size: _imageSize),\n                  ),\n                  const Expanded(child: Text('Guest')),\n                  divider,\n                  IconButton(\n                    tooltip: 'Add Account',\n                    icon: const Icon(Icons.add_rounded),\n                    onPressed: () => _addAccount(accounts.isEmpty),\n                  ),\n                ],\n              ),\n            ),\n          );\n\n          return PillSelector(\n            maxWidth: 380,\n            shrinkWrap: true,\n            selected: accountGroup.accountIndex ?? accounts.length,\n            items: items,\n            onTap: (i) async {\n              if (i == accounts.length) {\n                ref.read(persistenceProvider.notifier).switchAccount(null);\n                Navigator.pop(context);\n                return;\n              }\n\n              if (DateTime.now().isBefore(accounts[i].expiration)) {\n                ref.read(persistenceProvider.notifier).switchAccount(i);\n                Navigator.pop(context);\n                return;\n              }\n\n              var ok = false;\n              await ConfirmationDialog.show(\n                context,\n                title: 'Session expired',\n                content: 'Do you want to log in again?',\n                primaryAction: 'Yes',\n                secondaryAction: 'No',\n                onConfirm: () => ok = true,\n              );\n\n              if (ok) _addAccount(accounts.isEmpty);\n            },\n          );\n        },\n      ),\n    );\n  }\n\n  void _addAccount(bool isAccountListEmpty) {\n    if (isAccountListEmpty) {\n      SnackBarExtension.launch(context, _loginLink);\n      return;\n    }\n\n    ConfirmationDialog.show(\n      context,\n      title: 'Add an Account',\n      content:\n          'To add more accounts, make sure you\\'re logged out of the previous ones in the browser.',\n      primaryAction: 'Continue',\n      secondaryAction: 'Cancel',\n      onConfirm: () {\n        if (mounted) {\n          SnackBarExtension.launch(context, _loginLink);\n        }\n      },\n    );\n  }\n}\n\nclass _FollowButton extends StatefulWidget {\n  const _FollowButton(this.user, this.toggleFollow);\n\n  final User user;\n  final Future<Object?> Function() toggleFollow;\n\n  @override\n  State<_FollowButton> createState() => __FollowButtonState();\n}\n\nclass __FollowButtonState extends State<_FollowButton> {\n  @override\n  Widget build(BuildContext context) {\n    final user = widget.user;\n\n    return Padding(\n      padding: const .all(Theming.offset),\n      child: ElevatedButton.icon(\n        icon: Icon(\n          user.isFollowed ? Ionicons.person_remove_outline : Ionicons.person_add_outline,\n          size: Theming.iconSmall,\n        ),\n        label: Text(\n          user.isFollowed\n              ? user.isFollower\n                    ? 'Mutual'\n                    : 'Following'\n              : user.isFollower\n              ? 'Follower'\n              : 'Follow',\n        ),\n        onPressed: () {\n          final isFollowed = user.isFollowed;\n          setState(() => user.isFollowed = !isFollowed);\n\n          widget.toggleFollow().then((err) {\n            if (err == null) return;\n\n            setState(() => user.isFollowed = isFollowed);\n\n            if (context.mounted) {\n              SnackBarExtension.show(context, err.toString());\n            }\n          });\n        },\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/user/user_item_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/user/user_item_model.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\n\nclass UserItemGrid extends StatelessWidget {\n  const UserItemGrid(this.items, {required this.highContrast});\n\n  final List<UserItem> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);\n    final textHeight = lineHeight * 2 + 10;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight(\n        minWidth: 100,\n        extraHeight: textHeight,\n      ),\n      delegate: SliverChildBuilderDelegate(\n        (_, i) => _Tile(items[i], highContrast, textHeight),\n        childCount: items.length,\n      ),\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile(this.item, this.highContrast, this.textHeight);\n\n  final UserItem item;\n  final bool highContrast;\n  final double textHeight;\n\n  @override\n  Widget build(BuildContext context) {\n    return InkWell(\n      borderRadius: Theming.borderRadiusSmall,\n      onTap: () => context.push(Routes.user(item.id, item.imageUrl)),\n      child: CardExtension.highContrast(highContrast)(\n        child: Column(\n          spacing: 5,\n          children: [\n            Expanded(\n              child: Hero(\n                tag: item.id,\n                child: ClipRRect(\n                  borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall),\n                  child: CachedImage(item.imageUrl),\n                ),\n              ),\n            ),\n            SizedBox(\n              height: textHeight,\n              child: Padding(\n                padding: const .all(5),\n                child: Text(item.name, maxLines: 2, overflow: .ellipsis),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/user/user_item_model.dart",
    "content": "class UserItem {\n  const UserItem._({required this.id, required this.name, required this.imageUrl});\n\n  factory UserItem(Map<String, dynamic> map) =>\n      UserItem._(id: map['id'], name: map['name'], imageUrl: map['avatar']['large']);\n\n  final int id;\n  final String name;\n  final String imageUrl;\n}\n"
  },
  {
    "path": "lib/feature/user/user_model.dart",
    "content": "import 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/extension/string_extension.dart';\nimport 'package:otraku/util/markdown.dart';\nimport 'package:otraku/feature/statistics/statistics_model.dart';\n\nclass User {\n  User._({\n    required this.id,\n    required this.name,\n    required this.createdAt,\n    required this.description,\n    required this.imageUrl,\n    required this.bannerUrl,\n    required this.siteUrl,\n    required this.isFollowed,\n    required this.isFollower,\n    required this.isBlocked,\n    required this.donatorTier,\n    required this.donatorBadge,\n    required this.modRoles,\n    required this.animeStats,\n    required this.mangaStats,\n  });\n\n  factory User(Map<String, dynamic> map) {\n    final modRoles = <String>[];\n    if (map['moderatorRoles'] != null) {\n      for (String r in map['moderatorRoles']) {\n        modRoles.add(r.noScreamingSnakeCase);\n      }\n    }\n\n    return User._(\n      id: map['id'],\n      name: map['name'],\n      createdAt: map['createdAt'] != null\n          ? DateTime.fromMillisecondsSinceEpoch(map['createdAt'] * 1000).formattedDate\n          : null,\n      description: parseMarkdown(map['about'] ?? ''),\n      imageUrl: map['avatar']['large'],\n      bannerUrl: map['bannerImage'],\n      siteUrl: map['siteUrl'],\n      isFollowed: map['isFollowing'] ?? false,\n      isFollower: map['isFollower'] ?? false,\n      isBlocked: map['isBlocked'] ?? false,\n      donatorTier: map['donatorTier'] ?? 0,\n      donatorBadge: map['donatorBadge'] ?? '',\n      modRoles: modRoles,\n      animeStats: Statistics(map['statistics']['anime'], true),\n      mangaStats: Statistics(map['statistics']['manga'], false),\n    );\n  }\n\n  final int id;\n  final String name;\n  final String? createdAt;\n  final String description;\n  final String imageUrl;\n  final String? bannerUrl;\n  final String? siteUrl;\n  bool isFollowed;\n  final bool isFollower;\n  final bool isBlocked;\n  final int donatorTier;\n  final String donatorBadge;\n  final List<String> modRoles;\n  final Statistics animeStats;\n  final Statistics mangaStats;\n}\n"
  },
  {
    "path": "lib/feature/user/user_providers.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/extension/future_extension.dart';\nimport 'package:otraku/feature/user/user_model.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/graphql.dart';\n\ntypedef UserTag = ({int? id, String? name});\n\nUserTag idUserTag(int id) => (id: id, name: null);\n\nUserTag nameUserTag(String name) => (id: null, name: name);\n\nfinal userProvider = AsyncNotifierProvider.autoDispose.family<UserNotifier, User, UserTag>(\n  UserNotifier.new,\n);\n\nclass UserNotifier extends AsyncNotifier<User> {\n  UserNotifier(this.arg);\n\n  final UserTag arg;\n\n  @override\n  FutureOr<User> build() async {\n    final data = await ref\n        .read(repositoryProvider)\n        .request(GqlQuery.user, arg.id != null ? {'id': arg.id} : {'name': arg.name});\n    return User(data['User']);\n  }\n\n  Future<Object?> toggleFollow(int userId) {\n    return ref.read(repositoryProvider).request(GqlMutation.toggleFollow, {\n      'userId': userId,\n    }).getErrorOrNull();\n  }\n}\n"
  },
  {
    "path": "lib/feature/user/user_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\nimport 'package:otraku/feature/user/user_model.dart';\nimport 'package:otraku/feature/user/user_providers.dart';\nimport 'package:otraku/feature/user/user_header.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/loaders.dart';\n\nclass UserView extends StatelessWidget {\n  const UserView(this.tag, this.avatarUrl);\n\n  final UserTag tag;\n  final String? avatarUrl;\n\n  @override\n  Widget build(BuildContext context) => AdaptiveScaffold(child: _UserView(tag, avatarUrl));\n}\n\n/// The home page has app bars,\n/// but the one on the user tab should be transparent\n/// and the padding should be removed.\nclass UserHomeView extends StatelessWidget {\n  const UserHomeView(\n    this.tag,\n    this.avatarUrl, {\n    this.homeScrollCtrl,\n    required this.removableTopPadding,\n  });\n\n  final UserTag? tag;\n  final String? avatarUrl;\n  final ScrollController? homeScrollCtrl;\n  final double removableTopPadding;\n\n  @override\n  Widget build(BuildContext context) {\n    final body = tag != null\n        ? _UserView(tag!, avatarUrl, homeScrollCtrl)\n        : CustomScrollView(\n            controller: homeScrollCtrl,\n            physics: Theming.bouncyPhysics,\n            slivers: [\n              UserHeader(\n                id: null,\n                user: null,\n                isViewer: true,\n                imageUrl: null,\n                toggleFollow: () async => null,\n              ),\n              const SliverToBoxAdapter(\n                child: Center(\n                  child: Padding(\n                    padding: Theming.paddingAll,\n                    child: Text(\n                      'Log in with the profile icon at the top to view your account',\n                      textAlign: .center,\n                    ),\n                  ),\n                ),\n              ),\n              const SliverFooter(),\n            ],\n          );\n\n    final mediaQuery = MediaQuery.of(context);\n    return MediaQuery(\n      data: mediaQuery.copyWith(\n        padding: mediaQuery.padding.copyWith(top: mediaQuery.padding.top - removableTopPadding),\n      ),\n      child: body,\n    );\n  }\n}\n\nclass _UserView extends StatelessWidget {\n  const _UserView(this.tag, this.avatarUrl, [this.scrollCtrl]);\n\n  final UserTag tag;\n  final String? avatarUrl;\n  final ScrollController? scrollCtrl;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        final persistence = ref.watch(persistenceProvider);\n        final highContrast = persistence.options.highContrast;\n        final viewer = persistence.accountGroup.account;\n\n        final isViewer =\n            viewer != null && (tag.id != null ? tag.id == viewer.id : tag.name == viewer.name);\n\n        ref.listen<AsyncValue<User>>(\n          userProvider(tag),\n          (_, s) => s.whenOrNull(\n            data: (data) {\n              if (!isViewer) return;\n\n              ref.read(persistenceProvider.notifier).refreshViewerDetails(data.name, data.imageUrl);\n            },\n            error: (error, _) => SnackBarExtension.show(context, error.toString()),\n          ),\n        );\n\n        final user = ref.watch(userProvider(tag));\n\n        final header = UserHeader(\n          id: tag.id,\n          user: user.value,\n          isViewer: isViewer,\n          imageUrl: avatarUrl ?? user.value?.imageUrl,\n          toggleFollow: () {\n            final userId = user.value?.id;\n            if (userId == null) return Future.value(false);\n\n            return ref.read(userProvider(tag).notifier).toggleFollow(userId);\n          },\n        );\n\n        final mediaQuery = MediaQuery.of(context);\n\n        final refreshControl = MediaQuery(\n          data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)),\n          child: SliverRefreshControl(onRefresh: () => ref.invalidate(userProvider(tag))),\n        );\n\n        return user.unwrapPrevious().when(\n          error: (_, _) => CustomScrollView(\n            physics: Theming.bouncyPhysics,\n            slivers: [\n              header,\n              refreshControl,\n              const SliverFillRemaining(child: Center(child: Text('Failed to load user'))),\n            ],\n          ),\n          loading: () => CustomScrollView(\n            slivers: [\n              header,\n              const SliverFillRemaining(child: Center(child: Loader())),\n            ],\n          ),\n          data: (data) => CustomScrollView(\n            controller: scrollCtrl,\n            physics: Theming.bouncyPhysics,\n            slivers: [\n              header,\n              refreshControl,\n              _ButtonRow(data.id, isViewer, highContrast),\n              if (data.description.isNotEmpty) ...[\n                const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)),\n                SliverConstrainedView(\n                  sliver: HtmlContent(data.description, renderMode: RenderMode.sliverList),\n                ),\n              ],\n              const SliverFooter(),\n            ],\n          ),\n        );\n      },\n    );\n  }\n}\n\nclass _ButtonRow extends StatelessWidget {\n  const _ButtonRow(this.userId, this.isViewer, this.highContrast);\n\n  final int userId;\n  final bool isViewer;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    final buttonHeight =\n        Theming.iconBig +\n        context.lineHeight(TextTheme.of(context).bodyMedium!) +\n        Theming.offset * 2.5;\n\n    final buttons = [\n      _Button(\n        label: 'Anime',\n        icon: Ionicons.film,\n        highContrast: highContrast,\n        onTap: () => isViewer\n            ? context.go(Routes.home(.anime))\n            : context.push(Routes.animeCollection(userId)),\n      ),\n      _Button(\n        label: 'Manga',\n        icon: Ionicons.book,\n        highContrast: highContrast,\n        onTap: () => isViewer\n            ? context.go(Routes.home(.manga))\n            : context.push(Routes.mangaCollection(userId)),\n      ),\n      _Button(\n        label: 'Activities',\n        icon: Ionicons.chatbox,\n        highContrast: highContrast,\n        onTap: () => context.push(Routes.activities(userId)),\n      ),\n      _Button(\n        label: 'Social',\n        icon: Ionicons.people_circle,\n        highContrast: highContrast,\n        onTap: () => context.push(Routes.social(userId)),\n      ),\n      _Button(\n        label: 'Favourites',\n        icon: Icons.favorite,\n        highContrast: highContrast,\n        onTap: () => context.push(Routes.favorites(userId)),\n      ),\n      _Button(\n        label: 'Statistics',\n        icon: Ionicons.stats_chart,\n        highContrast: highContrast,\n        onTap: () => context.push(Routes.statistics(userId)),\n      ),\n      _Button(\n        label: 'Reviews',\n        icon: Icons.rate_review,\n        highContrast: highContrast,\n        onTap: () => context.push(Routes.reviews(userId)),\n      ),\n    ];\n\n    return SliverPadding(\n      padding: const .symmetric(horizontal: Theming.offset, vertical: Theming.offset),\n      sliver: SliverGrid(\n        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(\n          crossAxisCount: 3,\n          mainAxisSpacing: Theming.offset,\n          crossAxisSpacing: Theming.offset,\n          mainAxisExtent: buttonHeight,\n        ),\n        delegate: SliverChildBuilderDelegate(\n          (context, i) => buttons[i],\n          childCount: buttons.length,\n        ),\n      ),\n    );\n  }\n}\n\nclass _Button extends StatelessWidget {\n  const _Button({\n    required this.label,\n    required this.icon,\n    required this.highContrast,\n    required this.onTap,\n  });\n\n  final String label;\n  final IconData icon;\n  final bool highContrast;\n  final void Function() onTap;\n\n  @override\n  Widget build(BuildContext context) {\n    return CardExtension.highContrast(highContrast)(\n      child: InkWell(\n        onTap: onTap,\n        borderRadius: Theming.borderRadiusSmall,\n        child: Padding(\n          padding: Theming.paddingAll,\n          child: Column(mainAxisAlignment: .spaceBetween, children: [Icon(icon), Text(label)]),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/feature/viewer/persistence_model.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/enum_extension.dart';\nimport 'package:otraku/feature/activity/activities_filter_model.dart';\nimport 'package:otraku/feature/calendar/calendar_models.dart';\nimport 'package:otraku/feature/collection/collection_filter_model.dart';\nimport 'package:otraku/feature/collection/collection_models.dart';\nimport 'package:otraku/feature/discover/discover_filter_model.dart';\nimport 'package:otraku/feature/discover/discover_model.dart';\nimport 'package:otraku/feature/home/home_model.dart';\nimport 'package:otraku/util/theming.dart';\n\nconst appVersion = '1.12.1';\n\nclass Persistence {\n  const Persistence({\n    required this.systemColors,\n    required this.accountGroup,\n    required this.options,\n    required this.appMeta,\n    required this.animeCollectionMediaFilter,\n    required this.mangaCollectionMediaFilter,\n    required this.discoverMediaFilter,\n    required this.homeActivitiesFilter,\n    required this.mediaActivitiesFilter,\n    required this.calendarFilter,\n  });\n\n  factory Persistence.empty() => Persistence(\n    systemColors: (lightPrimaryColor: null, darkPrimaryColor: null),\n    accountGroup: .empty(),\n    options: .empty(),\n    appMeta: .empty(),\n    animeCollectionMediaFilter: CollectionMediaFilter(),\n    mangaCollectionMediaFilter: CollectionMediaFilter(),\n    discoverMediaFilter: DiscoverMediaFilter(.titleRomaji),\n    homeActivitiesFilter: .empty(),\n    mediaActivitiesFilter: .empty(),\n    calendarFilter: .empty(),\n  );\n\n  factory Persistence.fromPersistenceMap(\n    Map<dynamic, dynamic> map,\n    Map<String, String> accessTokens,\n  ) {\n    final accountGroup = AccountGroup.fromPersistenceMap(\n      map['accountGroup'] ?? const {},\n      accessTokens,\n    );\n\n    return Persistence(\n      systemColors: (lightPrimaryColor: null, darkPrimaryColor: null),\n      accountGroup: accountGroup,\n      options: .fromPersistenceMap(map['options'] ?? const {}),\n      appMeta: .fromPersistenceMap(map['appMeta'] ?? const {}),\n      animeCollectionMediaFilter: .fromPersistenceMap(\n        map['animeCollectionMediaFilter'] ?? const {},\n      ),\n      mangaCollectionMediaFilter: .fromPersistenceMap(\n        map['mangaCollectionMediaFilter'] ?? const {},\n      ),\n      discoverMediaFilter: .fromPersistenceMap(map['discoverMediaFilter'] ?? const {}),\n      homeActivitiesFilter: .fromPersistenceMap(\n        map['homeActivitiesFilter'] ?? const {},\n        accountGroup.account?.id,\n      ),\n      mediaActivitiesFilter: .fromPersistence(\n        map['mediaActivitiesFilter'] ?? const {},\n        0,\n        accountGroup.account?.id,\n      ),\n      calendarFilter: .fromPersistenceMap(map['calendarFilter'] ?? const {}),\n    );\n  }\n\n  final SystemColors systemColors;\n  final AccountGroup accountGroup;\n  final Options options;\n  final AppMeta appMeta;\n  final CollectionMediaFilter animeCollectionMediaFilter;\n  final CollectionMediaFilter mangaCollectionMediaFilter;\n  final DiscoverMediaFilter discoverMediaFilter;\n  final HomeActivitiesFilter homeActivitiesFilter;\n  final MediaActivitiesFilter mediaActivitiesFilter;\n  final CalendarFilter calendarFilter;\n\n  Persistence copyWith({\n    SystemColors? systemColors,\n    AccountGroup? accountGroup,\n    Options? options,\n    AppMeta? appMeta,\n    CollectionMediaFilter? animeCollectionMediaFilter,\n    CollectionMediaFilter? mangaCollectionMediaFilter,\n    DiscoverMediaFilter? discoverMediaFilter,\n    HomeActivitiesFilter? homeActivitiesFilter,\n    CalendarFilter? calendarFilter,\n    MediaActivitiesFilter? mediaActivitiesFilter,\n  }) => Persistence(\n    systemColors: systemColors ?? this.systemColors,\n    accountGroup: accountGroup ?? this.accountGroup,\n    options: options ?? this.options,\n    appMeta: appMeta ?? this.appMeta,\n    animeCollectionMediaFilter: animeCollectionMediaFilter ?? this.animeCollectionMediaFilter,\n    mangaCollectionMediaFilter: mangaCollectionMediaFilter ?? this.mangaCollectionMediaFilter,\n    discoverMediaFilter: discoverMediaFilter ?? this.discoverMediaFilter,\n    homeActivitiesFilter: homeActivitiesFilter ?? this.homeActivitiesFilter,\n    calendarFilter: calendarFilter ?? this.calendarFilter,\n    mediaActivitiesFilter: mediaActivitiesFilter ?? this.mediaActivitiesFilter,\n  );\n}\n\ntypedef SystemColors = ({Color? lightPrimaryColor, Color? darkPrimaryColor});\n\nclass AccountGroup {\n  const AccountGroup({required this.accounts, required this.accountIndex});\n\n  factory AccountGroup.empty() => const AccountGroup(accounts: [], accountIndex: null);\n\n  factory AccountGroup.fromPersistenceMap(\n    Map<dynamic, dynamic> map,\n    Map<String, String> accessTokens,\n  ) {\n    final accounts = <Account>[];\n    for (final a in map['accounts'] ?? const []) {\n      final accessToken = accessTokens[Account.accessTokenKeyById(a['id'])];\n      if (accessToken == null) continue;\n\n      accounts.add(.fromPersistenceMap(a, accessToken));\n    }\n\n    int? accountIndex = map['accountIndex']?.clamp(0, accounts.length - 1);\n\n    // Can't use an account whose token has expired.\n    if (accountIndex != null && accounts[accountIndex].expiration.compareTo(DateTime.now()) <= 0) {\n      accountIndex = null;\n    }\n\n    return AccountGroup(accounts: accounts, accountIndex: accountIndex);\n  }\n\n  final List<Account> accounts;\n  final int? accountIndex;\n\n  Account? get account => accountIndex != null ? accounts[accountIndex!] : null;\n\n  Map<String, dynamic> toPersistenceMap() => {\n    'accounts': accounts.map((a) => a.toPersistenceMap()).toList(),\n    'accountIndex': accountIndex,\n  };\n}\n\nclass Account {\n  const Account({\n    required this.id,\n    required this.name,\n    required this.avatarUrl,\n    required this.expiration,\n    required this.accessToken,\n  });\n\n  factory Account.fromPersistenceMap(Map<dynamic, dynamic> map, String accessToken) => Account(\n    id: map['id'],\n    name: map['name'],\n    avatarUrl: map['avatarUrl'],\n    expiration: map['expiration'],\n    accessToken: accessToken,\n  );\n\n  final int id;\n  final String name;\n  final String avatarUrl;\n  final DateTime expiration;\n  final String accessToken;\n\n  static String accessTokenKeyById(int id) => 'auth$id';\n\n  Map<String, dynamic> toPersistenceMap() => {\n    'id': id,\n    'name': name,\n    'avatarUrl': avatarUrl,\n    'expiration': expiration,\n  };\n}\n\nclass Options {\n  const Options({\n    required this.themeMode,\n    required this.themeBase,\n    required this.highContrast,\n    required this.homeTab,\n    required this.discoverType,\n    required this.imageQuality,\n    required this.animeCollectionPreview,\n    required this.mangaCollectionPreview,\n    required this.confirmExit,\n    required this.analogClock,\n    required this.buttonOrientation,\n    required this.discoverItemView,\n    required this.collectionItemView,\n    required this.collectionPreviewItemView,\n  });\n\n  factory Options.empty() => const Options(\n    themeMode: ThemeMode.system,\n    themeBase: null,\n    highContrast: false,\n    homeTab: .feed,\n    discoverType: .anime,\n    imageQuality: .high,\n    animeCollectionPreview: true,\n    mangaCollectionPreview: true,\n    confirmExit: false,\n    analogClock: false,\n    buttonOrientation: .auto,\n    discoverItemView: .detailed,\n    collectionItemView: .detailed,\n    collectionPreviewItemView: .detailed,\n  );\n\n  factory Options.fromPersistenceMap(Map<dynamic, dynamic> map) => Options(\n    themeMode: ThemeMode.values.getOrFirst(map['themeMode']),\n    themeBase: ThemeBase.values.getOrNull(map['themeBase']),\n    highContrast: map['highContrast'] ?? false,\n    homeTab: HomeTab.values.getOrFirst(map['homeTab']),\n    discoverType: DiscoverType.values.getOrFirst(map['discoverType']),\n    imageQuality: ImageQuality.values.getOrNull(map['imageQuality']) ?? .high,\n    animeCollectionPreview: map['animeCollectionPreview'] ?? true,\n    mangaCollectionPreview: map['mangaCollectionPreview'] ?? true,\n    confirmExit: map['confirmExit'] ?? false,\n    buttonOrientation: ButtonOrientation.values.getOrFirst(map['buttonOrientation']),\n    analogClock: map['analogClock'] ?? false,\n    discoverItemView: DiscoverItemView.values.getOrFirst(map['discoverItemView']),\n    collectionItemView: .values.getOrFirst(map['collectionItemView']),\n    collectionPreviewItemView: .values.getOrFirst(map['collectionPreviewItemView']),\n  );\n\n  final ThemeMode themeMode;\n  final ThemeBase? themeBase;\n  final bool highContrast;\n  final HomeTab homeTab;\n  final DiscoverType discoverType;\n  final ImageQuality imageQuality;\n  final bool animeCollectionPreview;\n  final bool mangaCollectionPreview;\n  final bool confirmExit;\n  final bool analogClock;\n  final ButtonOrientation buttonOrientation;\n  final DiscoverItemView discoverItemView;\n  final CollectionItemView collectionItemView;\n  final CollectionItemView collectionPreviewItemView;\n\n  Options copyWith({\n    ThemeMode? themeMode,\n    (ThemeBase?,)? themeBase,\n    bool? highContrast,\n    HomeTab? homeTab,\n    DiscoverType? discoverType,\n    ImageQuality? imageQuality,\n    bool? animeCollectionPreview,\n    bool? mangaCollectionPreview,\n    bool? confirmExit,\n    bool? analogClock,\n    ButtonOrientation? buttonOrientation,\n    DiscoverItemView? discoverItemView,\n    CollectionItemView? collectionItemView,\n    CollectionItemView? collectionPreviewItemView,\n  }) => Options(\n    themeMode: themeMode ?? this.themeMode,\n    themeBase: themeBase == null ? this.themeBase : themeBase.$1,\n    highContrast: highContrast ?? this.highContrast,\n    homeTab: homeTab ?? this.homeTab,\n    discoverType: discoverType ?? this.discoverType,\n    imageQuality: imageQuality ?? this.imageQuality,\n    animeCollectionPreview: animeCollectionPreview ?? this.animeCollectionPreview,\n    mangaCollectionPreview: mangaCollectionPreview ?? this.mangaCollectionPreview,\n    confirmExit: confirmExit ?? this.confirmExit,\n    buttonOrientation: buttonOrientation ?? this.buttonOrientation,\n    analogClock: analogClock ?? this.analogClock,\n    discoverItemView: discoverItemView ?? this.discoverItemView,\n    collectionItemView: collectionItemView ?? this.collectionItemView,\n    collectionPreviewItemView: collectionPreviewItemView ?? this.collectionPreviewItemView,\n  );\n\n  Map<String, dynamic> toPersistenceMap() => {\n    'themeMode': themeMode.index,\n    'themeBase': themeBase?.index,\n    'highContrast': highContrast,\n    'homeTab': homeTab.index,\n    'discoverType': discoverType.index,\n    'imageQuality': imageQuality.index,\n    'animeCollectionPreview': animeCollectionPreview,\n    'mangaCollectionPreview': mangaCollectionPreview,\n    'confirmExit': confirmExit,\n    'analogClock': analogClock,\n    'buttonOrientation': buttonOrientation.index,\n    'discoverItemView': discoverItemView.index,\n    'collectionItemView': collectionItemView.index,\n    'collectionPreviewItemView': collectionPreviewItemView.index,\n  };\n}\n\nenum ImageQuality {\n  veryHigh('Very High', 'extraLarge'),\n  high('High', 'large'),\n  medium('Medium', 'medium');\n\n  const ImageQuality(this.label, this.value);\n\n  final String label;\n  final String value;\n\n  // Character and staff images don't have an \"extra large\" option.\n  String get personValue => switch (this) {\n    .veryHigh => ImageQuality.high.value,\n    _ => value,\n  };\n}\n\nenum ButtonOrientation { auto, left, right }\n\nclass AppMeta {\n  const AppMeta({\n    required this.lastNotificationId,\n    required this.lastAppVersion,\n    required this.lastBackgroundJob,\n  });\n\n  factory AppMeta.empty() =>\n      const AppMeta(lastNotificationId: -1, lastAppVersion: '', lastBackgroundJob: null);\n\n  factory AppMeta.fromPersistenceMap(Map<dynamic, dynamic> map) => AppMeta(\n    lastNotificationId: map['lastNotificationId'] ?? -1,\n    lastAppVersion: map['lastAppVersion'] ?? '',\n    lastBackgroundJob: map['lastBackgroundJob'],\n  );\n\n  final int lastNotificationId;\n  final String lastAppVersion;\n  final DateTime? lastBackgroundJob;\n\n  Map<String, dynamic> toPersistenceMap() => {\n    'lastNotificationId': lastNotificationId,\n    'lastAppVersion': lastAppVersion,\n    'lastBackgroundJob': lastBackgroundJob,\n  };\n}\n"
  },
  {
    "path": "lib/feature/viewer/persistence_provider.dart",
    "content": "import 'package:flutter/foundation.dart';\nimport 'package:flutter/widgets.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:flutter_secure_storage/flutter_secure_storage.dart';\nimport 'package:hive/hive.dart';\nimport 'package:otraku/feature/activity/activities_filter_model.dart';\nimport 'package:otraku/feature/calendar/calendar_models.dart';\nimport 'package:otraku/feature/collection/collection_filter_model.dart';\nimport 'package:otraku/feature/discover/discover_filter_model.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/util/background_handler.dart';\nimport 'package:path_provider/path_provider.dart';\n\nfinal persistenceProvider = NotifierProvider<PersistenceNotifier, Persistence>(\n  PersistenceNotifier.new,\n);\n\nfinal viewerIdProvider = persistenceProvider.select((s) => s.accountGroup.account?.id);\n\nclass PersistenceNotifier extends Notifier<Persistence> {\n  late Box<Map<dynamic, dynamic>> _box;\n\n  @override\n  Persistence build() => .empty();\n\n  Future<void> init() async {\n    WidgetsFlutterBinding.ensureInitialized();\n\n    // Configure home directory, if not in the browser.\n    if (!kIsWeb) Hive.init((await getApplicationDocumentsDirectory()).path);\n\n    _box = await Hive.openBox('persistence');\n    final accessTokens = await const FlutterSecureStorage().readAll();\n\n    state = .fromPersistenceMap(_box.toMap(), accessTokens);\n  }\n\n  void cacheSystemPrimaryColors(SystemColors systemColors) {\n    state = state.copyWith(systemColors: systemColors);\n  }\n\n  void setOptions(Options options) {\n    _box.put('options', options.toPersistenceMap());\n    state = state.copyWith(options: options);\n  }\n\n  void setAppMeta(AppMeta appMeta) {\n    _box.put('appMeta', appMeta.toPersistenceMap());\n    state = state.copyWith(appMeta: appMeta);\n  }\n\n  void setAnimeCollectionMediaFilter(CollectionMediaFilter mediaFilter) {\n    _box.put('animeCollectionMediaFilter', mediaFilter.toPersistenceMap());\n    state = state.copyWith(animeCollectionMediaFilter: mediaFilter);\n  }\n\n  void setMangaCollectionMediaFilter(CollectionMediaFilter mediaFilter) {\n    _box.put('mangaCollectionMediaFilter', mediaFilter.toPersistenceMap());\n    state = state.copyWith(mangaCollectionMediaFilter: mediaFilter);\n  }\n\n  void setDiscoverMediaFilter(DiscoverMediaFilter discoverMediaFilter) {\n    _box.put('discoverMediaFilter', discoverMediaFilter.toPersistenceMap());\n    state = state.copyWith(discoverMediaFilter: discoverMediaFilter);\n  }\n\n  void setHomeActivitiesFilter(HomeActivitiesFilter homeActivitiesFilter) {\n    _box.put('homeActivitiesFilter', homeActivitiesFilter.toPersistenceMap());\n    state = state.copyWith(homeActivitiesFilter: homeActivitiesFilter);\n  }\n\n  void setMediaActivitiesFilter(MediaActivitiesFilter mediaActivitiesFilter) {\n    _box.put('mediaActivitiesFilter', mediaActivitiesFilter.toPersistenceMap());\n    state = state.copyWith(mediaActivitiesFilter: mediaActivitiesFilter);\n  }\n\n  void setCalendarFilter(CalendarFilter calendarFilter) {\n    _box.put('calendarFilter', calendarFilter.toPersistenceMap());\n    state = state.copyWith(calendarFilter: calendarFilter);\n  }\n\n  void refreshViewerDetails(String newName, String newAvatarUrl) {\n    final accounts = state.accountGroup.accounts;\n    final accountIndex = state.accountGroup.accountIndex;\n\n    if (accountIndex == null) return;\n    final account = accounts[accountIndex];\n\n    if (account.name == newName && account.avatarUrl == newAvatarUrl) return;\n\n    _setAccountGroup(\n      AccountGroup(\n        accounts: [\n          ...accounts.sublist(0, accountIndex),\n          Account(\n            name: newName,\n            avatarUrl: newAvatarUrl,\n            id: account.id,\n            expiration: account.expiration,\n            accessToken: account.accessToken,\n          ),\n          ...accounts.sublist(accountIndex + 1),\n        ],\n        accountIndex: accountIndex,\n      ),\n    );\n  }\n\n  /// Switches active account.\n  /// Don't switch to an account whose token has expired.\n  void switchAccount(int? index) {\n    final accountGroup = state.accountGroup;\n\n    if (index == accountGroup.accountIndex) return;\n    if (index != null && (index < 0 || index >= accountGroup.accounts.length)) {\n      return;\n    }\n\n    if (index == null) BackgroundHandler.clearNotifications();\n\n    _setAccountGroup(AccountGroup(accountIndex: index, accounts: accountGroup.accounts));\n  }\n\n  Future<void> addAccount(Account account) async {\n    final accounts = state.accountGroup.accounts;\n    final accountIndex = state.accountGroup.accountIndex;\n\n    await const FlutterSecureStorage().write(\n      key: Account.accessTokenKeyById(account.id),\n      value: account.accessToken,\n    );\n\n    for (int i = 0; i < accounts.length; i++) {\n      if (accounts[i].id == account.id) {\n        _setAccountGroup(\n          AccountGroup(\n            accounts: [...accounts.sublist(0, i), account, ...accounts.sublist(i + 1)],\n            accountIndex: accountIndex,\n          ),\n        );\n\n        switchAccount(i);\n        return;\n      }\n    }\n\n    _setAccountGroup(AccountGroup(accounts: [...accounts, account], accountIndex: accountIndex));\n\n    switchAccount(state.accountGroup.accounts.length - 1);\n  }\n\n  Future<void> removeAccount(int index) async {\n    final accountGroup = state.accountGroup;\n\n    if (index == accountGroup.accountIndex) return;\n    if (index < 0 || index >= accountGroup.accounts.length) return;\n\n    final account = accountGroup.accounts[index];\n    await const FlutterSecureStorage().delete(key: Account.accessTokenKeyById(account.id));\n\n    _setAccountGroup(\n      AccountGroup(\n        accounts: [\n          ...accountGroup.accounts.sublist(0, index),\n          ...accountGroup.accounts.sublist(index + 1),\n        ],\n        accountIndex: accountGroup.accountIndex,\n      ),\n    );\n  }\n\n  /// Persists the account changes, but doesn't affect secure storage.\n  /// Token changes must be handled separately.\n  void _setAccountGroup(AccountGroup accountGroup) {\n    _box.put('accountGroup', accountGroup.toPersistenceMap());\n    state = state.copyWith(accountGroup: accountGroup);\n  }\n}\n"
  },
  {
    "path": "lib/feature/viewer/repository_model.dart",
    "content": "import 'dart:async';\nimport 'dart:convert';\nimport 'dart:io';\n\nimport 'package:http/http.dart';\n\nclass Repository {\n  static final _url = Uri.parse('https://graphql.anilist.co');\n\n  Repository(String? accessToken)\n    : _headers = {\n        'Accept': 'application/json',\n        'Content-type': 'application/json',\n        if (accessToken != null) 'Authorization': 'Bearer $accessToken',\n      };\n\n  final Map<String, String> _headers;\n\n  Future<Map<String, dynamic>> request(\n    String query, [\n    Map<String, dynamic> variables = const {},\n  ]) async {\n    try {\n      final response = await post(\n        _url,\n        body: json.encode({'query': query, 'variables': variables}),\n        headers: _headers,\n      ).timeout(const Duration(seconds: 30));\n\n      final Map<String, dynamic> body = json.decode(response.body);\n\n      if (body.containsKey('errors')) {\n        throw StateError((body['errors'] as List).map((e) => e['message'].toString()).join(', '));\n      }\n\n      return body['data'];\n    } on SocketException {\n      throw Exception('Failed to connect');\n    } on TimeoutException {\n      throw Exception('Request took too long');\n    }\n  }\n}\n"
  },
  {
    "path": "lib/feature/viewer/repository_provider.dart",
    "content": "import 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_model.dart';\n\nfinal repositoryProvider = NotifierProvider<RepositoryNotifier, Repository>(RepositoryNotifier.new);\n\nclass RepositoryNotifier extends Notifier<Repository> {\n  @override\n  Repository build() {\n    final accessToken = ref.watch(\n      persistenceProvider.select((s) => s.accountGroup.account?.accessToken),\n    );\n\n    return Repository(accessToken);\n  }\n\n  Future<Account?> initAccount(String token, int secondsUntilExpiration) async {\n    try {\n      final data = await Repository(\n        token,\n      ).request('query Viewer {Viewer {id name avatar {large}}}');\n\n      final id = data['Viewer']?['id'];\n      final name = data['Viewer']?['name'];\n      final avatarUrl = data['Viewer']?['avatar']?['large'];\n      if (id == null || name == null || avatarUrl == null) {\n        return null;\n      }\n\n      final expiration = DateTime.now().add(Duration(seconds: secondsUntilExpiration, days: -1));\n\n      return Account(\n        id: id,\n        name: name,\n        avatarUrl: avatarUrl,\n        expiration: expiration,\n        accessToken: token,\n      );\n    } catch (_) {\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "lib/main.dart",
    "content": "import 'dart:async';\n\nimport 'package:dynamic_color/dynamic_color.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/background_handler.dart';\nimport 'package:otraku/util/theming.dart';\n\nFuture<void> main() async {\n  final container = ProviderContainer(retry: (retryCount, error) => null);\n  await container.read(persistenceProvider.notifier).init();\n  BackgroundHandler.init(_notificationCtrl);\n\n  SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);\n  SystemChrome.setSystemUIOverlayStyle(\n    const SystemUiOverlayStyle(\n      statusBarColor: Colors.transparent,\n      systemStatusBarContrastEnforced: false,\n      systemNavigationBarColor: Colors.transparent,\n      systemNavigationBarContrastEnforced: false,\n    ),\n  );\n\n  runApp(UncontrolledProviderScope(container: container, child: const _App()));\n}\n\nfinal _notificationCtrl = StreamController<String>.broadcast();\n\nclass _App extends ConsumerStatefulWidget {\n  const _App();\n\n  @override\n  AppState createState() => AppState();\n}\n\nclass AppState extends ConsumerState<_App> {\n  late final GoRouter _router;\n  late final StreamSubscription<String> _notificationSubscription;\n  Color? _systemLightPrimaryColor;\n  Color? _systemDarkPrimaryColor;\n\n  @override\n  void initState() {\n    super.initState();\n\n    final mustConfirmExit = () => ref.read(persistenceProvider).options.confirmExit;\n\n    _router = Routes.buildRouter(mustConfirmExit);\n\n    _notificationSubscription = _notificationCtrl.stream.listen(_router.push);\n\n    var appMeta = ref.read(persistenceProvider).appMeta;\n    if (appMeta.lastAppVersion != appVersion) {\n      appMeta = AppMeta(\n        lastAppVersion: appVersion,\n        lastNotificationId: appMeta.lastNotificationId,\n        lastBackgroundJob: appMeta.lastBackgroundJob,\n      );\n\n      WidgetsBinding.instance.addPostFrameCallback(\n        (_) => ref.read(persistenceProvider.notifier).setAppMeta(appMeta),\n      );\n\n      BackgroundHandler.requestPermissionForNotifications();\n    }\n  }\n\n  @override\n  void dispose() {\n    _notificationSubscription.cancel();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    ref.watch(viewerIdProvider);\n    final options = ref.watch(persistenceProvider.select((s) => s.options));\n    final platformBrightness = MediaQuery.platformBrightnessOf(context);\n    final viewSize = MediaQuery.sizeOf(context);\n\n    return DynamicColorBuilder(\n      builder: (lightDynamic, darkDynamic) {\n        Color lightSeed = (options.themeBase ?? .navy).seed;\n        Color darkSeed = lightSeed;\n        if (lightDynamic != null && darkDynamic != null) {\n          _systemLightPrimaryColor = lightDynamic.primary;\n          _systemDarkPrimaryColor = darkDynamic.primary;\n\n          // The system primary colors must be cached,\n          // so they can later be used in the settings.\n          final notifier = ref.watch(persistenceProvider.notifier);\n\n          // A provider can't be modified during build,\n          // so it's done asynchronously as a workaround.\n          Future(\n            () => notifier.cacheSystemPrimaryColors((\n              lightPrimaryColor: _systemLightPrimaryColor,\n              darkPrimaryColor: _systemDarkPrimaryColor,\n            )),\n          );\n\n          if (options.themeBase == null &&\n              _systemLightPrimaryColor != null &&\n              _systemDarkPrimaryColor != null) {\n            lightSeed = _systemLightPrimaryColor!;\n            darkSeed = _systemDarkPrimaryColor!;\n          }\n        }\n\n        Color? lightBackground;\n        Color? darkBackground;\n        if (options.highContrast) {\n          lightBackground = Colors.white;\n          darkBackground = Colors.black;\n        }\n\n        final lightScheme = ColorScheme.fromSeed(\n          seedColor: lightSeed,\n          brightness: Brightness.light,\n        ).copyWith(surface: lightBackground);\n        final darkScheme = ColorScheme.fromSeed(\n          seedColor: darkSeed,\n          brightness: Brightness.dark,\n        ).copyWith(surface: darkBackground);\n\n        final isDark = options.themeMode == ThemeMode.system\n            ? platformBrightness == Brightness.dark\n            : options.themeMode == ThemeMode.dark;\n\n        final ColorScheme scheme;\n        final Brightness overlayBrightness;\n        if (isDark) {\n          scheme = darkScheme;\n          overlayBrightness = Brightness.light;\n        } else {\n          scheme = lightScheme;\n          overlayBrightness = Brightness.dark;\n        }\n\n        SystemChrome.setSystemUIOverlayStyle(\n          SystemUiOverlayStyle(\n            statusBarBrightness: scheme.brightness,\n            statusBarIconBrightness: overlayBrightness,\n            systemNavigationBarIconBrightness: overlayBrightness,\n          ),\n        );\n\n        return MaterialApp.router(\n          debugShowCheckedModeBanner: false,\n          title: 'Otraku',\n          theme: Theming.generateThemeData(lightScheme),\n          darkTheme: Theming.generateThemeData(darkScheme),\n          themeMode: options.themeMode,\n          routerConfig: _router,\n          builder: (context, child) {\n            final directionality = Directionality.of(context);\n\n            final theming = Theming(\n              formFactor: viewSize.width < Theming.windowWidthMedium ? .phone : .tablet,\n              rightButtonOrientation: options.buttonOrientation == .auto\n                  ? directionality == TextDirection.ltr\n                  : options.buttonOrientation == .right,\n            );\n\n            return Theme(\n              data: Theme.of(context).copyWith(extensions: [theming]),\n              child: child!,\n            );\n          },\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/util/background_handler.dart",
    "content": "import 'dart:async';\nimport 'dart:io';\n\nimport 'package:flutter_local_notifications/flutter_local_notifications.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:otraku/feature/viewer/persistence_model.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/feature/notification/notifications_model.dart';\nimport 'package:otraku/util/graphql.dart';\nimport 'package:workmanager/workmanager.dart';\n\nfinal _notificationPlugin = FlutterLocalNotificationsPlugin();\n\nclass BackgroundHandler {\n  BackgroundHandler._();\n\n  static Future<void> init(StreamController<String> notificationCtrl) async {\n    _notificationPlugin.initialize(\n      settings: const InitializationSettings(\n        android: AndroidInitializationSettings('notification_icon'),\n        iOS: DarwinInitializationSettings(),\n      ),\n      onDidReceiveNotificationResponse: (response) {\n        if (response.payload == null) return;\n        notificationCtrl.add(response.payload!);\n      },\n    );\n\n    // Check if the app was launched by a notification.\n    _notificationPlugin.getNotificationAppLaunchDetails().then((launchDetails) {\n      if (launchDetails?.notificationResponse?.payload == null) return;\n      notificationCtrl.add(launchDetails!.notificationResponse!.payload!);\n    });\n\n    await Workmanager().initialize(_fetch);\n\n    if (Platform.isAndroid) {\n      Workmanager().registerPeriodicTask(\n        '0',\n        'notifications',\n        constraints: Constraints(networkType: NetworkType.connected),\n      );\n    }\n  }\n\n  /// Requests a notifications permission, if not already granted.\n  static Future<void> requestPermissionForNotifications() async {\n    if (Platform.isAndroid) {\n      final platform = _notificationPlugin\n          .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();\n      if (platform == null) return;\n\n      if (await platform.areNotificationsEnabled() ?? false) return;\n\n      await platform.requestNotificationsPermission();\n      return;\n    }\n\n    if (Platform.isIOS) {\n      final platform = _notificationPlugin\n          .resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();\n      if (platform == null) return;\n\n      final permissions = await platform.checkPermissions();\n      if (permissions?.isEnabled ?? false) return;\n\n      await platform.requestPermissions(sound: true, badge: true);\n      return;\n    }\n  }\n\n  /// Clears device notifications.\n  static void clearNotifications() => _notificationPlugin.cancelAll();\n}\n\n@pragma('vm:entry-point')\nvoid _fetch() => Workmanager().executeTask((_, _) async {\n  final container = ProviderContainer(retry: (retryCount, error) => null);\n\n  await container.read(persistenceProvider.notifier).init();\n  final persistence = container.read(persistenceProvider);\n\n  // No notifications are fetched in guest mode.\n  if (persistence.accountGroup.accountIndex == null) return true;\n\n  var appMeta = AppMeta(\n    lastBackgroundJob: DateTime.now(),\n    lastNotificationId: persistence.appMeta.lastNotificationId,\n    lastAppVersion: persistence.appMeta.lastAppVersion,\n  );\n  container.read(persistenceProvider.notifier).setAppMeta(appMeta);\n\n  final repository = container.read(repositoryProvider);\n  Map<String, dynamic> data;\n  try {\n    data = await repository.request(GqlQuery.notifications, const {'withCount': true});\n  } catch (_) {\n    return true;\n  }\n\n  int count = data['Viewer']?['unreadNotificationCount'] ?? 0;\n  final List<dynamic> notifications = data['Page']?['notifications'] ?? const [];\n\n  if (count > notifications.length) count = notifications.length;\n  if (count == 0) return true;\n\n  final lastNotificationId = persistence.appMeta.lastNotificationId;\n\n  appMeta = AppMeta(\n    lastNotificationId: notifications[0]['id'] ?? -1,\n    lastBackgroundJob: persistence.appMeta.lastBackgroundJob,\n    lastAppVersion: persistence.appMeta.lastAppVersion,\n  );\n  container.read(persistenceProvider.notifier).setAppMeta(appMeta);\n\n  for (int i = 0; i < count && notifications[i]['id'] != lastNotificationId; i++) {\n    final notification = SiteNotification.maybe(notifications[i], persistence.options.imageQuality);\n\n    if (notification == null) continue;\n\n    (switch (notification.type) {\n      .following => _show(\n        notification,\n        'New Follow',\n        Routes.user((notification as FollowNotification).userId),\n      ),\n      .activityMention => _show(\n        notification,\n        'New Mention',\n        Routes.activity((notification as ActivityNotification).activityId),\n      ),\n      .activityMessage => _show(\n        notification,\n        'New Message',\n        Routes.activity((notification as ActivityNotification).activityId),\n      ),\n      .activityReply => _show(\n        notification,\n        'New Reply',\n        Routes.activity((notification as ActivityNotification).activityId),\n      ),\n      .activityReplySubscribed => _show(\n        notification,\n        'New Reply To Subscribed Activity',\n        Routes.activity((notification as ActivityNotification).activityId),\n      ),\n      .activityLike => _show(\n        notification,\n        'New Activity Like',\n        Routes.activity((notification as ActivityNotification).activityId),\n      ),\n      .acrivityReplyLike => _show(\n        notification,\n        'New Reply Like',\n        Routes.activity((notification as ActivityNotification).activityId),\n      ),\n      .threadLike => _show(\n        notification,\n        'New Forum Like',\n        Routes.thread((notification as ThreadNotification).threadId),\n      ),\n      .threadCommentReply => _show(\n        notification,\n        'New Forum Reply',\n        Routes.comment((notification as ThreadCommentNotification).commentId),\n      ),\n      .threadCommentMention => _show(\n        notification,\n        'New Forum Mention',\n        Routes.comment((notification as ThreadCommentNotification).commentId),\n      ),\n      .threadReplySubscribed => _show(\n        notification,\n        'New Forum Comment',\n        Routes.comment((notification as ThreadCommentNotification).commentId),\n      ),\n      .threadCommentLike => _show(\n        notification,\n        'New Forum Comment Like',\n        Routes.comment((notification as ThreadCommentNotification).commentId),\n      ),\n      .airing => _show(\n        notification,\n        'New Episode',\n        Routes.media((notification as MediaReleaseNotification).mediaId),\n      ),\n      .relatedMediaAddition => _show(\n        notification,\n        'Added Media',\n        Routes.media((notification as MediaReleaseNotification).mediaId),\n      ),\n      .mediaDataChange => _show(\n        notification,\n        'Modified Media',\n        Routes.media((notification as MediaChangeNotification).mediaId),\n      ),\n      .mediaMerge => _show(\n        notification,\n        'Merged Media',\n        Routes.media((notification as MediaChangeNotification).mediaId),\n      ),\n      .mediaDeletion => _show(notification, 'Deleted Media', Routes.notifications),\n      .mediaSubmissionUpdate => _show(\n        notification,\n        'Media Submission Update',\n        Routes.notifications,\n      ),\n      .characterSubmissionUpdate => _show(\n        notification,\n        'Character Submission Update',\n        Routes.notifications,\n      ),\n      .staffSubmissionUpdate => _show(\n        notification,\n        'Staff Submission Update',\n        Routes.notifications,\n      ),\n    });\n  }\n\n  return true;\n});\n\n() _show(SiteNotification notification, String title, String payload) {\n  _notificationPlugin.show(\n    id: notification.id,\n    title: title,\n    body: notification.texts.join(),\n    payload: payload,\n    notificationDetails: NotificationDetails(\n      android: AndroidNotificationDetails(\n        notification.type.name,\n        notification.type.label,\n        channelDescription: notification.type.label,\n      ),\n    ),\n  );\n  return ();\n}\n"
  },
  {
    "path": "lib/util/debounce.dart",
    "content": "import 'dart:async';\n\n/// After [_delay] time has passed, since the last [run] call, call [callback].\n/// E.g. do a search query after the user stops typing.\nclass Debounce {\n  static const _delay = Duration(milliseconds: 600);\n\n  Timer? _timer;\n\n  void cancel() => _timer?.cancel();\n\n  void run(void Function() callback) {\n    _timer?.cancel();\n    _timer = Timer(_delay, callback);\n  }\n}\n"
  },
  {
    "path": "lib/util/graphql.dart",
    "content": "abstract class GqlQuery {\n  static const collection =\n      r'''\n    query Collection($userId: Int, $type: MediaType, $status_in: [MediaListStatus]) {\n      MediaListCollection(userId: $userId, type: $type, status_in: $status_in) {\n        lists {name isCustomList isSplitCompletedList status entries {...collectionEntry}}\n        user {\n          mediaListOptions {\n            rowOrder\n            scoreFormat\n            animeList {sectionOrder splitCompletedSectionByFormat}\n            mangaList {sectionOrder splitCompletedSectionByFormat}\n          }\n        }\n      }\n    }\n  '''\n      '${_GqlFragment.collectionEntry}';\n\n  static const listEntry =\n      r'''\n    query CollectionEntry($userId: Int, $mediaId: Int) {\n      MediaList(userId: $userId, mediaId: $mediaId) {\n        ...collectionEntry customLists hiddenFromStatusLists\n      }\n    }\n  '''\n      '${_GqlFragment.collectionEntry}';\n\n  static const media = r'''\n    query Media($id: Int, $withInfo: Boolean = false, $withRecommendations: Boolean = false,\n        $withCharacters: Boolean = false, $withStaff: Boolean = false,\n        $withReviews: Boolean = false, $page: Int = 1) {\n      Media(id: $id) {\n        mediaListEntry @include(if: $withInfo) {...entry}\n        ...info @include(if: $withInfo)\n        ...recommendations @include (if: $withRecommendations)\n        ...characters @include(if: $withCharacters)\n        ...staff @include(if: $withStaff)\n        ...reviews @include(if: $withReviews)\n      }\n    }\n    fragment info on Media {\n      id\n      type\n      title {userPreferred english romaji native}\n      synonyms\n      description\n      coverImage {extraLarge large medium}\n      bannerImage\n      episodes\n      chapters\n      volumes\n      format\n      status(version: 2)\n      startDate {year month day}\n      endDate {year month day}\n      nextAiringEpisode {episode airingAt}\n      countryOfOrigin\n      genres\n      tags {id}\n      isAdult\n      hashtag\n      isFavourite\n      favourites\n      duration\n      season\n      seasonYear\n      averageScore\n      meanScore\n      popularity\n      studios {edges {isMain node {id name}}}\n      tags {name description rank isMediaSpoiler isGeneralSpoiler}\n      source(version: 3)\n      hashtag\n      siteUrl\n      rankings {rank type year season allTime}\n      stats {scoreDistribution {score amount} statusDistribution {status amount}}\n      externalLinks {url site type color language}\n      relations {\n        edges {\n          relationType(version: 2)\n          node {\n            id\n            type\n            format\n            title {userPreferred} \n            status(version: 2)\n            coverImage {extraLarge large medium}\n            mediaListEntry {status}\n          }\n        }\n      }\n    }\n    fragment entry on MediaList {\n      id\n      status\n      progress\n      progressVolumes\n      score\n      repeat\n      notes\n      startedAt {year month day}\n      completedAt {year month day}\n      private\n      hiddenFromStatusLists\n      customLists\n      advancedScores\n      updatedAt\n      createdAt\n    }\n    fragment characters on Media {\n      characters(page: $page, sort: [ROLE, RELEVANCE, ID]) {\n        pageInfo {hasNextPage}\n        edges {\n          role\n          node {id name {userPreferred} image {large}}\n          voiceActors(sort: RELEVANCE) {\n            id\n            name {userPreferred}\n            image {large}\n            languageV2\n          }\n        }\n      }\n    }\n    fragment staff on Media {\n      staff(page: $page, sort: [RELEVANCE, ID]) {\n        pageInfo {hasNextPage}\n        edges {role node {id name {userPreferred} image {large}}}\n      }\n    }\n    fragment reviews on Media {\n      reviews(sort: RATING_DESC, page: $page) {\n        pageInfo {hasNextPage}\n        nodes {\n          id\n          summary\n          score\n          rating\n          ratingAmount\n          user {id name avatar {large}}\n        }\n      }\n    }\n    fragment recommendations on Media {\n      recommendations(page: $page, sort: [RATING_DESC]) {\n        pageInfo {hasNextPage}\n        nodes {\n          rating\n          userRating\n          mediaRecommendation {\n            id\n            type\n            title {userPreferred}\n            coverImage {extraLarge large medium}\n            format\n            startDate {year}\n            mediaListEntry {status}\n          }\n        }\n      }\n    }\n  ''';\n\n  static const mediaFollowing = r'''\n    query MediaFollowing($mediaId: Int, $page: Int) {\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        mediaList(mediaId: $mediaId, isFollowing: true, sort: UPDATED_TIME_DESC) {\n          status\n          score\n          notes\n          user {\n            id\n            name\n            avatar {large}\n            mediaListOptions {scoreFormat}\n          }\n        }\n      }\n    }\n  ''';\n\n  static const entry = r'''\n    query Entry($mediaId: Int) {\n      Media(id: $mediaId) {\n        id\n        type\n        episodes\n        chapters\n        volumes\n        mediaListEntry {\n          id\n          status\n          progress\n          progressVolumes\n          repeat\n          notes\n          startedAt {year month day}\n          completedAt {year month day}\n          score\n          advancedScores\n          private\n          hiddenFromStatusLists\n          customLists\n        }\n      }\n    }\n  ''';\n\n  static const mediaPage = r'''\n    query Media($page: Int, $type: MediaType, $search:String, $status_in: [MediaStatus],\n        $format_in: [MediaFormat], $genre_in: [String], $genre_not_in: [String],\n        $tag_in: [String], $tag_not_in: [String], $onList: Boolean, $startFrom: FuzzyDateInt,\n        $startTo: FuzzyDateInt, $countryOfOrigin: CountryCode, $season: MediaSeason,\n        $sources: [MediaSource], $isAdult: Boolean, $isLicensed: Boolean, $sort: [MediaSort]) {\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        media(type: $type, search: $search, status_in: $status_in, format_in: $format_in,\n        genre_in: $genre_in, genre_not_in: $genre_not_in, tag_in: $tag_in, tag_not_in: $tag_not_in, \n        onList: $onList, startDate_greater: $startFrom, startDate_lesser: $startTo, isAdult: $isAdult,\n        isLicensed: $isLicensed, countryOfOrigin: $countryOfOrigin, season: $season,\n        source_in: $sources, sort: $sort) {\n          id\n          type\n          title {userPreferred}\n          coverImage {extraLarge large medium}\n          format\n          status(version: 2)\n          averageScore\n          popularity\n          startDate {year}\n          isAdult\n          mediaListEntry {status}\n        }\n      }\n    }\n  ''';\n\n  static const character = r'''\n    query Character($id: Int, $sort: [MediaSort], $page: Int = 1, $onList: Boolean,\n        $withInfo: Boolean = false, $withAnime: Boolean = false, $withManga: Boolean = false) {\n      Character(id: $id) {\n        ...info @include(if: $withInfo)\n        anime: media(page: $page, type: ANIME, onList: $onList, sort: $sort) \n          @include(if: $withAnime) {...media}\n        manga: media(page: $page, type: MANGA, onList: $onList, sort: $sort) \n          @include(if: $withManga) {...media}\n      }\n    }\n    fragment info on Character {\n      id\n      name{first middle last native alternative alternativeSpoiler}\n      image{large}\n      description\n      dateOfBirth{year month day}\n      bloodType\n      gender\n      age\n      favourites \n      isFavourite\n      siteUrl\n    }\n    fragment media on MediaConnection {\n      pageInfo {hasNextPage}\n      edges {\n        characterRole\n        voiceActors(sort: [LANGUAGE]) {id name {userPreferred} image {large} languageV2}\n        node {id type title {userPreferred} coverImage {extraLarge large medium}}\n      }\n    }\n  ''';\n\n  static const characterPage = r'''\n    query Characters($page: Int, $search: String, $isBirthday: Boolean) {\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        characters(search: $search, sort: FAVOURITES_DESC, isBirthday: $isBirthday) {\n          id name {userPreferred} image {large}\n        }\n      }\n    }\n  ''';\n\n  static const staff = r'''\n    query Staff($id: Int, $sort: [MediaSort], $page: Int = 1, $type: MediaType, $onList: Boolean,\n        $withInfo: Boolean = false, $withCharacters: Boolean = false, $withRoles: Boolean = false) {\n      Staff(id: $id) {\n        ...info @include(if: $withInfo)\n        characterMedia(page: $page, sort: $sort, onList: $onList) @include(if: $withCharacters) {\n          pageInfo {hasNextPage}\n          edges {\n            characterRole\n            node {\n              id\n              type\n              title {userPreferred}\n              coverImage {extraLarge large medium}\n              format\n            }\n            characters {\n              id\n              name {userPreferred}\n              image {large}\n            }\n          }\n        }\n        staffMedia(page: $page, sort: $sort, type: $type, onList: $onList) @include(if: $withRoles) {\n          pageInfo {hasNextPage}\n          edges {\n            staffRole\n            node {\n              id\n              type\n              title {userPreferred}\n              coverImage {extraLarge large medium}\n            }\n          }\n        }\n      }\n    }\n    fragment info on Staff {\n      id\n      name{first middle last native alternative}\n      image{large}\n      description\n      dateOfBirth{year month day}\n      dateOfDeath{year month day}\n      gender\n      age\n      yearsActive\n      bloodType\n      homeTown\n      favourites \n      isFavourite\n      siteUrl\n    }\n  ''';\n\n  static const staffPage = r'''\n    query Staff($page: Int, $search: String, $isBirthday: Boolean) {\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        staff(search: $search, sort: FAVOURITES_DESC, isBirthday: $isBirthday) {\n          id name {userPreferred} image {large}\n        }\n      }\n    }\n  ''';\n\n  static const studio = r'''\n    query Studio($id: Int, $page: Int = 1, $sort: [MediaSort], $onList: Boolean, $isMain: Boolean, $withInfo: Boolean = false, $withMedia: Boolean = false) {\n      Studio(id: $id) {\n        ...info @include(if: $withInfo)\n        media(page: $page, sort: $sort, onList: $onList, isMain: $isMain) @include(if: $withMedia) {\n          pageInfo {hasNextPage}\n          nodes {\n            id\n            title {userPreferred}\n            coverImage {extraLarge large medium}\n            format\n            status(version: 2)\n            averageScore\n            mediaListEntry {status}\n            startDate {year month day}\n          }\n        }\n      }\n    }\n    fragment info on Studio {id name favourites isFavourite siteUrl}\n  ''';\n\n  static const studioPage = r'''\n    query Studios($page: Int, $search: String) {\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        studios(search: $search, sort: FAVOURITES_DESC) {id name}\n      }\n    }\n  ''';\n\n  static const review = r'''\n    query Review($id: Int) {\n      Review(id: $id) {\n        id\n        summary\n        body\n        score\n        rating\n        ratingAmount\n        userRating\n        createdAt\n        siteUrl\n        media {id type title {userPreferred} coverImage {extraLarge large medium} bannerImage}\n        user {id name avatar {large}}\n      }\n    }\n  ''';\n\n  static const reviewPage = r'''\n    query Reviews($userId: Int, $page: Int = 1, $mediaType: MediaType, $sort: [ReviewSort]) {\n      Page(page: $page) {\n        pageInfo {hasNextPage total}\n        reviews(userId: $userId, mediaType: $mediaType, sort: $sort) {\n          id\n          summary\n          rating\n          ratingAmount\n          media {id type title {userPreferred} bannerImage}\n          user {id name}\n        }\n      }\n    }\n  ''';\n\n  static const user = r'''\n      query User($id: Int, $name: String) {\n        User(id: $id, name: $name) {\n          id\n          name\n          createdAt\n          about\n          avatar {large}\n          bannerImage\n          isFollowing\n          isFollower\n          isBlocked\n          siteUrl\n          donatorTier\n          donatorBadge\n          moderatorRoles\n          statistics {anime {...stats} manga {...stats}}\n        }\n      }\n      fragment stats on UserStatistics {\n        count\n        meanScore\n        standardDeviation\n        minutesWatched\n        episodesWatched\n        chaptersRead\n        volumesRead\n        scores(sort: MEAN_SCORE) {count meanScore minutesWatched chaptersRead score}\n        lengths {count meanScore minutesWatched chaptersRead length}\n        formats {count meanScore minutesWatched chaptersRead format}\n        statuses {count meanScore minutesWatched chaptersRead status}\n        countries {count meanScore minutesWatched chaptersRead country}\n      }\n    ''';\n\n  static const userPage = r'''\n    query Users($page: Int, $search: String) {\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        users(search: $search) {id name avatar {large}}\n      }\n    }\n  ''';\n\n  static const recommendationsPage = r'''\n    query Recommendations($page: Int, $sort: [RecommendationSort], $onList: Boolean) {\n      Page(page: $page, perPage: 30) {\n        pageInfo {hasNextPage}\n        recommendations(sort: $sort, onList: $onList) {\n          rating\n          userRating\n          media {\n            id\n            type\n            title {userPreferred}\n            coverImage {extraLarge large medium}\n            mediaListEntry {status}\n            isAdult\n          }\n          mediaRecommendation {\n            id\n            type\n            title {userPreferred}\n            coverImage {extraLarge large medium}\n            mediaListEntry {status}\n            isAdult\n          }\n        }\n      }\n    }\n  ''';\n\n  static const calendar = r'''\n    query Calendar($page: Int, $airingFrom: Int, $airingTo: Int) {\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        airingSchedules(airingAt_greater: $airingFrom, airingAt_lesser: $airingTo) {\n          airingAt\n          episode\n          mediaId\n          media {\n            title {userPreferred}\n            coverImage {extraLarge large medium}\n            season\n            seasonYear\n            mediaListEntry {status}\n            externalLinks {url site type color language}\n          }\n        }\n      }\n    }\n  ''';\n\n  static const favorites = r'''\n    query Favorites($userId: Int, $page: Int = 1, $withAnime: Boolean = false,\n      $withManga: Boolean = false, $withCharacters: Boolean = false,\n      $withStaff: Boolean = false, $withStudios: Boolean = false) {\n      User(id: $userId) {\n        favourites {\n          anime(page: $page) @include(if: $withAnime) {...media}\n          manga(page: $page) @include(if: $withManga) {...media}\n          characters(page: $page) @include(if: $withCharacters) {...character}\n          staff(page: $page) @include(if: $withStaff) {...staff}\n          studios(page: $page) @include(if: $withStudios) {...studio}\n        }\n      }\n    }\n    fragment media on MediaConnection {pageInfo {hasNextPage total} nodes {id title {userPreferred} coverImage {extraLarge large medium}}}\n    fragment character on CharacterConnection {pageInfo {hasNextPage total} nodes {id name {userPreferred} image {large}}}\n    fragment staff on StaffConnection {pageInfo {hasNextPage total} nodes {id name {userPreferred} image {large}}}\n    fragment studio on StudioConnection {pageInfo {hasNextPage total} nodes {id name}}\n  ''';\n\n  static const social =\n      r'''\n    query Friends($userId: Int!, $page: Int = 1, $withFollowing: Boolean = false, $withFollowers: Boolean = false,\n        $withThreads: Boolean = false, $withComments: Boolean = false) {\n      following: Page(page: $page) @include(if: $withFollowing) {\n        pageInfo {hasNextPage total}\n        following(userId: $userId, sort: USERNAME) {id name avatar {large}}\n      }\n      followers: Page(page: $page) @include(if: $withFollowers) {\n        pageInfo {hasNextPage total}\n        followers(userId: $userId, sort: USERNAME) {id name avatar {large}}\n      }\n      threads: Page(page: $page) @include(if: $withThreads) {\n        pageInfo {hasNextPage total}\n        threads(userId: $userId, sort: ID_DESC) {...thread}\n      }\n      comments: Page(page: $page) @include(if: $withComments) {\n        pageInfo {hasNextPage total}\n        threadComments(userId: $userId, sort: ID_DESC) {\n          id\n          comment\n          likeCount\n          isLiked\n          isLocked\n          createdAt\n          siteUrl\n          user {id name avatar {large}}\n          thread {id title}\n        }\n      }\n    }\n  '''\n      '${_GqlFragment.thread}';\n\n  static const activity =\n      r'''\n    query Activity($id: Int, $withActivity: Boolean = false, $page: Int = 1) {\n      Activity(id: $id) @include(if: $withActivity) {\n        ... on TextActivity {...textActivity}\n        ... on ListActivity {...listActivity}\n        ... on MessageActivity {...messageActivity}\n      }\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        activityReplies(activityId: $id) {...activityReply}\n      }\n    }\n  '''\n      '${_GqlFragment.textActivity}${_GqlFragment.listActivity}${_GqlFragment.messageActivity}${_GqlFragment.activityReply}';\n\n  static const activityPage =\n      r'''\n    query Activities($userId: Int, $userIdNot: Int, $mediaId: Int, $page: Int = 1, $isFollowing: Boolean,\n        $hasRepliesOrText: Boolean, $typeIn: [ActivityType], $createdBefore: Int) {\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        activities(userId: $userId, userId_not: $userIdNot, mediaId: $mediaId, isFollowing: $isFollowing,\n            hasRepliesOrTypeText: $hasRepliesOrText, type_in: $typeIn, createdAt_lesser: $createdBefore, sort: [PINNED, ID_DESC]) {\n          ... on TextActivity {...textActivity}\n          ... on ListActivity {...listActivity}\n          ... on MessageActivity {...messageActivity}\n        }\n      }\n    }\n  '''\n      '${_GqlFragment.textActivity}${_GqlFragment.listActivity}${_GqlFragment.messageActivity}';\n\n  static const activityComposition = r'''\n    query ActivityComposition($id: Int) {\n      Activity(id: $id) {\n        ... on TextActivity {text}\n        ... on ListActivity {id}\n        ... on MessageActivity {message}\n      }\n    }\n  ''';\n\n  static const activityReplyComposition = r'''\n    query ActivityReplyComposition($id: Int) {\n      ActivityReply(id: $id) {text}\n    }\n  ''';\n\n  static const commentComposition = r'''\n    query CommentComposition($id: Int) {\n      ThreadComment(id: $id) {id comment childComments}\n    }\n  ''';\n\n  static const settings =\n      r'''\n    query Settings($withData: Boolean = true) {\n      Viewer {\n        unreadNotificationCount\n        ...userSettings @include(if: $withData)\n      }\n    }\n  '''\n      '${_GqlFragment.userSettings}';\n\n  static const threadPage =\n      r'''\n    query Forum($page: Int = 1, $search: String, $categoryId: Int, $mediaId: Int,\n        $subscribed: Boolean, $userId: Int, $replyUserId: Int, $sort: [ThreadSort]) {\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        threads(search: $search, categoryId: $categoryId, mediaCategoryId: $mediaId,\n            subscribed: $subscribed, userId: $userId, replyUserId: $replyUserId, sort: $sort) {\n          ...thread\n        }\n      }\n    }\n  '''\n      '${_GqlFragment.thread}';\n\n  static const thread = r'''\n    query Thread($id: Int, $withInfo: Boolean = false, $page: Int = 1) {\n      Thread(id: $id) @include(if: $withInfo) {\n        id\n        title\n        body\n        viewCount\n        likeCount\n        replyCount\n        isLiked\n        isSubscribed\n        isSticky\n        isLocked\n        createdAt\n        siteUrl\n        categories {name}\n        mediaCategories {id title {userPreferred} coverImage {extraLarge large medium}}\n        user {id name avatar {large}}\n      }\n      Page(page: $page, perPage: 15) {\n        pageInfo {currentPage lastPage}\n        threadComments(threadId: $id) {\n          id\n          comment\n          likeCount\n          isLiked\n          isLocked\n          createdAt\n          siteUrl\n          user {id name avatar {large}}\n          thread {id title}\n          childComments\n        }\n      }\n    }\n  ''';\n\n  static const comment = r'''\n    query Comment($id: Int) {\n      ThreadComment(id: $id) {\n        id\n        comment\n        likeCount\n        isLiked\n        isLocked\n        createdAt\n        siteUrl\n        user {id name avatar {large}}\n        thread {id title}\n        childComments\n      }\n    }\n  ''';\n\n  static const notifications = r'''\n    query Notifications($page: Int = 1, $filter: [NotificationType],\n        $withCount: Boolean = false, $resetCount: Boolean = false) {\n      Viewer @include(if: $withCount) {unreadNotificationCount}\n      Page(page: $page) {\n        pageInfo {hasNextPage}\n        notifications(type_in: $filter, resetNotificationCount: $resetCount) {\n          ... on FollowingNotification {\n            id\n            type\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ActivityMentionNotification {\n            id\n            type\n            activityId\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ActivityMessageNotification {\n            id\n            type\n            activityId\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ActivityLikeNotification {\n            id\n            type\n            activityId\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ActivityReplyNotification {\n            id\n            type\n            activityId\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ActivityReplyLikeNotification {\n            id\n            type\n            activityId\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ActivityReplySubscribedNotification {\n            id\n            type\n            activityId\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ThreadLikeNotification {\n            id\n            type\n            thread {id title siteUrl}\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ThreadCommentLikeNotification {\n            id\n            type\n            thread {title}\n            comment {id siteUrl}\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ThreadCommentReplyNotification {\n            id\n            type\n            context\n            thread {title}\n            comment {id siteUrl}\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ThreadCommentMentionNotification {\n            id\n            type\n            thread {title}\n            comment {id siteUrl}\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on ThreadCommentSubscribedNotification {\n            id\n            type\n            thread {title}\n            comment {id siteUrl}\n            user {id name avatar {large}}\n            createdAt\n          }\n          ... on RelatedMediaAdditionNotification {\n            id\n            type\n            media {id title {userPreferred} coverImage {extraLarge large medium}}\n            createdAt\n          }\n          ... on MediaDataChangeNotification {\n            id\n            type\n            reason\n            media {id title {userPreferred} coverImage {extraLarge large medium}}\n            createdAt\n          }\n          ... on MediaMergeNotification {\n            id\n            type\n            reason\n            deletedMediaTitles\n            media {id title {userPreferred} coverImage {extraLarge large medium}}\n            createdAt\n          }\n          ... on MediaDeletionNotification {\n            id\n            type\n            reason\n            deletedMediaTitle\n            createdAt\n          }\n          ... on AiringNotification {\n            id\n            type\n            episode\n            media {id title {userPreferred} coverImage {extraLarge large medium}}\n            createdAt\n          }\n          ... on MediaSubmissionUpdateNotification {\n            id\n            type\n            status\n            notes\n            media {id title {userPreferred} coverImage {extraLarge large medium}}\n            submittedTitle\n            createdAt\n          }\n          ... on CharacterSubmissionUpdateNotification {\n            id\n            type\n            status\n            notes\n            character {id name {userPreferred} image {large medium}}\n            createdAt\n          }\n          ... on StaffSubmissionUpdateNotification {\n            id\n            type\n            status\n            notes\n            staff {id name {userPreferred} image {large medium}}\n            createdAt\n          }\n        }\n      }\n    }\n  ''';\n\n  static const genresAndTags = '''\n    query Filters {\n      GenreCollection\n      MediaTagCollection {id name description category}\n    }\n  ''';\n}\n\nabstract class GqlMutation {\n  static const updateEntry = r'''\n    mutation UpdateEntry($mediaId: Int, $status: MediaListStatus,\n        $score: Float, $progress: Int, $progressVolumes: Int, $repeat: Int,\n        $private: Boolean, $notes: String, $hiddenFromStatusLists: Boolean,\n        $customLists: [String], $startedAt: FuzzyDateInput, $completedAt: FuzzyDateInput,\n        $advancedScores: [Float]) {\n      SaveMediaListEntry(mediaId: $mediaId, status: $status, score: $score,\n        progress: $progress, progressVolumes: $progressVolumes, repeat: $repeat,\n        private: $private, notes: $notes, hiddenFromStatusLists: $hiddenFromStatusLists,\n        customLists: $customLists, startedAt: $startedAt, completedAt: $completedAt,\n        advancedScores: $advancedScores) {id}\n    }\n  ''';\n\n  static const updateProgress = r'''\n    mutation UpdateProgress($mediaId: Int, $progress: Int, $status: MediaListStatus, $startedAt: FuzzyDateInput) {\n      SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status, startedAt: $startedAt) {id}\n    }\n  ''';\n\n  static const removeEntry = r'''\n    mutation RemoveEntry($entryId: Int) {DeleteMediaListEntry(id: $entryId) {deleted}}\n  ''';\n\n  static const updateSettings =\n      r'''\n    mutation UpdateSettings($titleLanguage: UserTitleLanguage, $staffNameLanguage: UserStaffNameLanguage, \n        $activityMergeTime: Int, $displayAdultContent: Boolean, $airingNotifications: Boolean, \n        $scoreFormat: ScoreFormat, $rowOrder: String, $notificationOptions: [NotificationOptionInput], \n        $splitCompletedAnime: Boolean, $splitCompletedManga: Boolean, $restrictMessagesToFollowing: Boolean,\n        $advancedScoringEnabled: Boolean, $advancedScoring: [String], $disabledListActivity: [ListActivityOptionInput],\n        $animeCustomLists: [String], $mangaCustomLists: [String]) {\n      UpdateUser(titleLanguage: $titleLanguage, staffNameLanguage: $staffNameLanguage,\n          activityMergeTime: $activityMergeTime, displayAdultContent: $displayAdultContent, \n          airingNotifications: $airingNotifications, restrictMessagesToFollowing: $restrictMessagesToFollowing,\n          scoreFormat: $scoreFormat, rowOrder: $rowOrder, notificationOptions: $notificationOptions,\n          disabledListActivity: $disabledListActivity,\n          animeListOptions: {splitCompletedSectionByFormat: $splitCompletedAnime, customLists: $animeCustomLists,\n          advancedScoringEnabled: $advancedScoringEnabled, advancedScoring: $advancedScoring},\n          mangaListOptions: {splitCompletedSectionByFormat: $splitCompletedManga, customLists: $mangaCustomLists}) {\n        ...userSettings\n      }\n    }\n  '''\n      '${_GqlFragment.userSettings}';\n\n  static const reorderFavorites = r'''\n    mutation ReorderFavorites($animeIds: [Int], $animeOrder: [Int], $mangaIds: [Int], $mangaOrder: [Int],\n        $characterIds: [Int], $characterOrder: [Int], $staffIds: [Int], $staffOrder: [Int], $studioIds: [Int], $studioOrder: [Int]) {\n      UpdateFavouriteOrder(animeIds: $animeIds, animeOrder: $animeOrder, mangaIds: $mangaIds, mangaOrder: $mangaOrder,\n          characterIds: $characterIds, characterOrder: $characterOrder, staffIds: $staffIds, staffOrder: $staffOrder,\n          studioIds: $studioIds, studioOrder: $studioOrder) {\n        anime {pageInfo {total}}\n      }\n    }\n  ''';\n\n  static const toggleFavorite = r'''\n    mutation ToggleFavorite($anime: Int, $manga: Int, $character: Int, $staff: Int, $studio: Int) {\n      ToggleFavourite(animeId: $anime, mangaId: $manga, characterId: $character, staffId: $staff, studioId: $studio) {\n        anime(page: 1, perPage: 1) {nodes{isFavourite}}\n        manga(page: 1, perPage: 1) {nodes{isFavourite}}\n        characters(page: 1, perPage: 1) {nodes{isFavourite}}\n        staff(page: 1, perPage: 1) {nodes{isFavourite}}\n        studios(page: 1, perPage: 1) {nodes{isFavourite}}\n      }\n    }\n  ''';\n\n  static const toggleFollow =\n      r'''mutation ToggleFollow($userId: Int) {ToggleFollow(userId: $userId) {isFollowing}}''';\n\n  static const rateReview = r'''\n    mutation RateReview($id: Int, $rating: ReviewRating) {\n      RateReview(reviewId: $id, rating: $rating) {\n        rating\n        ratingAmount\n        userRating\n      }\n    }\n  ''';\n\n  static const rateRecommendation = r'''\n    mutation RateRecommendation($id: Int, $recommendedId: Int, $rating: RecommendationRating) {\n      SaveRecommendation(mediaId: $id, mediaRecommendationId: $recommendedId, rating: $rating) {id}\n    }\n  ''';\n\n  static const saveStatusActivity =\n      r'''\n    mutation SaveStatusActivity($id: Int, $text: String) {\n      SaveTextActivity(id: $id, text: $text) {...textActivity}\n    }\n  '''\n      '${_GqlFragment.textActivity}';\n\n  static const saveMessageActivity =\n      r'''\n    mutation SaveMessageActivity($id: Int, $recipientId: Int, $text: String, $isPrivate: Boolean) {\n      SaveMessageActivity(id: $id, recipientId: $recipientId, message: $text, private: $isPrivate) {...messageActivity}\n    }\n  '''\n      '${_GqlFragment.messageActivity}';\n\n  static const saveActivityReply =\n      r'''\n    mutation SaveActivityReply($id: Int, $activityId: Int, $text: String) {\n      SaveActivityReply(id: $id, activityId: $activityId, text: $text) {...activityReply}\n    }\n  '''\n      '${_GqlFragment.activityReply}';\n\n  static const saveComment = r'''\n    mutation SaveComment($id: Int, $threadId: Int, $parentCommentId: Int, $text: String) {\n      SaveThreadComment(id: $id, threadId: $threadId, parentCommentId: $parentCommentId, comment: $text) {\n        id\n        comment\n        likeCount\n        isLiked\n        isLocked\n        createdAt\n        siteUrl\n        user {id name avatar {large}}\n        thread {id title}\n        childComments\n      }\n    }\n  ''';\n\n  static const toggleLike = r'''\n    mutation ToggleLike($id: Int, $type: LikeableType) {\n      ToggleLikeV2(id: $id, type: $type) {\n        ... on ListActivity {likeCount isLiked}\n        ... on TextActivity {likeCount isLiked}\n        ... on MessageActivity {likeCount isLiked}\n        ... on ActivityReply {likeCount isLiked}\n        ... on Thread {likeCount isLiked}\n        ... on ThreadComment {likeCount isLiked}\n      }\n    }\n  ''';\n\n  static const toggleActivitySubscription = r'''\n    mutation ToggleActivitySubscription($id: Int, $subscribe: Boolean) {\n      ToggleActivitySubscription(activityId: $id, subscribe: $subscribe) {\n        ... on ListActivity {isSubscribed}\n        ... on TextActivity {isSubscribed}\n        ... on MessageActivity {isSubscribed}\n      }\n    }\n  ''';\n\n  static const toggleActivityPin = r'''\n    mutation ToggleActivityPin($id: Int, $pinned: Boolean) {\n      ToggleActivityPin(id: $id, pinned: $pinned) {\n        ... on ListActivity {isPinned}\n        ... on TextActivity {isPinned}\n      }\n    }\n  ''';\n\n  static const deleteActivity = r'''\n    mutation DeleteActivity($id: Int) {DeleteActivity(id: $id) {deleted}}\n  ''';\n\n  static const deleteActivityReply = r'''\n    mutation DeleteActivityReply($id: Int) {DeleteActivityReply(id: $id) {deleted}}\n  ''';\n\n  static const toggleThreadSubscription = r'''\n    mutation ToggleThreadSubscription($id: Int, $subscribe: Boolean) {\n      ToggleThreadSubscription(threadId: $id, subscribe: $subscribe) {\n        isSubscribed\n      }\n    }\n  ''';\n\n  static const deleteThread = r'''\n    mutation DeleteThread($id: Int) {DeleteThread(id: $id) {deleted}}\n  ''';\n\n  static const deleteComment = r'''\n    mutation DeleteThreadComment($id: Int) {DeleteThreadComment(id: $id) {deleted}}\n  ''';\n}\n\nabstract class _GqlFragment {\n  static const collectionEntry = r'''\n    fragment collectionEntry on MediaList {\n      status\n      progress\n      score\n      notes\n      private\n      repeat\n      startedAt {year month day}\n      completedAt {year month day}\n      createdAt\n      updatedAt\n      media {\n        id\n        title {userPreferred romaji english native}\n        coverImage {extraLarge large medium}\n        format\n        status\n        episodes\n        chapters\n        averageScore\n        genres\n        tags {id}\n        nextAiringEpisode {episode airingAt}\n        startDate {year month day}\n        countryOfOrigin\n      }\n    }\n  ''';\n\n  static const userSettings = r'''\n    fragment userSettings on User {\n      options {\n        titleLanguage \n        staffNameLanguage\n        activityMergeTime\n        displayAdultContent\n        airingNotifications\n        notificationOptions {type enabled}\n        restrictMessagesToFollowing\n        disabledListActivity {type disabled}\n      }\n      mediaListOptions {\n        scoreFormat\n        rowOrder\n        animeList {splitCompletedSectionByFormat customLists advancedScoring advancedScoringEnabled}\n        mangaList {splitCompletedSectionByFormat customLists}\n      }\n    }  \n  ''';\n\n  static const textActivity = r'''\n    fragment textActivity on TextActivity {\n      id\n      type\n      replyCount\n      likeCount\n      isLiked\n      isSubscribed\n      isPinned\n      createdAt\n      siteUrl\n      text\n      user {id name avatar {large}}\n    }\n  ''';\n\n  static const messageActivity = r'''\n    fragment messageActivity on MessageActivity {\n      id\n      type\n      replyCount\n      likeCount\n      isLiked\n      isSubscribed\n      isPrivate\n      createdAt\n      siteUrl\n      message\n      messenger {id name avatar {large}}\n      recipient {id name avatar {large}}\n    }\n  ''';\n\n  static const activityReply = r'''\n    fragment activityReply on ActivityReply {\n      id\n      likeCount\n      isLiked\n      createdAt\n      text\n      user {id name avatar {large}}\n    }\n  ''';\n\n  static const listActivity = r'''\n    fragment listActivity on ListActivity {\n      id\n      type\n      replyCount\n      likeCount\n      isLiked\n      isSubscribed\n      isPinned\n      createdAt\n      siteUrl\n      user {id name avatar {large}}\n      media {id type title {userPreferred} coverImage {extraLarge large medium} format}\n      progress\n      status\n    }\n  ''';\n\n  static const thread = r'''\n    fragment thread on Thread {\n      id\n      title\n      viewCount\n      likeCount\n      replyCount\n      isSubscribed\n      isSticky\n      isLocked\n      siteUrl\n      createdAt\n      repliedAt\n      categories {name}\n      mediaCategories {title {userPreferred}}\n      user {id name avatar {large}}\n      replyUser {id name avatar {large}}\n    }\n  ''';\n}\n"
  },
  {
    "path": "lib/util/markdown.dart",
    "content": "import 'package:markdown/markdown.dart';\n\nString parseMarkdown(String markdown) {\n  // In case there's raw text, everything is wrapped in a paragraph tag.\n  final nodes = [Element('p', document.parse(markdown))];\n  return renderToHtml(nodes);\n}\n\nfinal document = Document(\n  blockSyntaxes: const [\n    _HeaderSyntax(),\n    _SpoilerBlockSyntax(),\n    _CenterBlockSyntax(),\n    _FencedCodeBlockSyntax(),\n    HorizontalRuleSyntax(),\n    BlockquoteSyntax(),\n    UnorderedListSyntax(),\n    OrderedListSyntax(),\n  ],\n  inlineSyntaxes: [\n    EmphasisSyntax.asterisk(),\n    EmphasisSyntax.underscore(),\n    StrikethroughSyntax(),\n    CodeSyntax(),\n    LinkSyntax(),\n    AutolinkExtensionSyntax(),\n    ImageSyntax(),\n    _ImageSyntax(),\n    _YouTubeSyntax(),\n    _VideoSyntax(),\n    _MentionSyntax(),\n    _LineBreakSyntax(),\n  ],\n  encodeHtml: false,\n  withDefaultBlockSyntaxes: false,\n  withDefaultInlineSyntaxes: false,\n  extensionSet: null,\n  linkResolver: null,\n  imageLinkResolver: null,\n);\n\n/// AniList allows empty spaces to be skipped after the sequence of \"#\".\nclass _HeaderSyntax extends HeaderSyntax {\n  const _HeaderSyntax();\n\n  static final _pattern = RegExp(r'^ {0,3}(#{1,6})(?:.*?)?(?:(#*)\\s*)?$');\n\n  @override\n  RegExp get pattern => _pattern;\n\n  @override\n  Node parse(BlockParser parser) {\n    final node = super.parse(parser) as Element;\n\n    // Directly parse inner content.\n    final children = node.children;\n    if (children != null && children.isNotEmpty) {\n      final parsedContent = BlockParser([\n        Line(children[0].textContent),\n      ], parser.document).parseLines();\n\n      children.clear();\n      children.addAll(parsedContent);\n    }\n\n    return node;\n  }\n}\n\nabstract class _DelimitedBlockSyntax extends BlockSyntax {\n  const _DelimitedBlockSyntax({\n    required this.tag,\n    required this.startDelimiter,\n    required this.endDelimiter,\n  });\n\n  final String tag;\n  final String startDelimiter;\n  final String endDelimiter;\n\n  void finalizeElement(Element element);\n\n  @override\n  Node parse(BlockParser parser) {\n    final lines = parseChildLines(parser);\n    if (lines.length < 3) return Element.withTag(tag);\n\n    final prefix = lines.first.content.isNotEmpty\n        ? BlockParser([lines.first], parser.document).parseLines()\n        : const [];\n    final postfix = lines.last.content.isNotEmpty\n        ? BlockParser([lines.last], parser.document).parseLines()\n        : const [];\n    final children = BlockParser(lines.sublist(1, lines.length - 1), parser.document).parseLines();\n\n    final element = Element(tag, children);\n    finalizeElement(element);\n    return prefix.isEmpty && postfix.isEmpty\n        ? element\n        : Element('p', [...prefix, element, ...postfix]);\n  }\n\n  @override\n  List<Line> parseChildLines(BlockParser parser) {\n    final childLines = <Line>[];\n    final text = parser.current.content;\n\n    int startIndex = text.indexOf(startDelimiter);\n    childLines.add(Line(text.substring(0, startIndex)));\n\n    startIndex += startDelimiter.length;\n    if (startIndex < text.length) {\n      final lineEnd = Line(text.substring(startIndex));\n      if (_close(parser, childLines, lineEnd)) return childLines;\n    } else {\n      parser.advance();\n    }\n\n    while (!parser.isDone) {\n      if (_close(parser, childLines, parser.current)) return childLines;\n    }\n\n    return childLines;\n  }\n\n  bool _close(BlockParser parser, List<Line> childLines, Line line) {\n    final text = line.content;\n    int endIndex = text.indexOf(endDelimiter);\n\n    if (endIndex < 0) {\n      childLines.add(line);\n      parser.advance();\n      return false;\n    }\n\n    childLines.add(Line(text.substring(0, endIndex)));\n    childLines.add(Line(text.substring(endIndex + endDelimiter.length)));\n\n    parser.advance();\n    return true;\n  }\n}\n\nclass _SpoilerBlockSyntax extends _DelimitedBlockSyntax {\n  const _SpoilerBlockSyntax()\n    : super(tag: 'details', startDelimiter: _startDelimiter, endDelimiter: '!~');\n\n  static const _startDelimiter = '~!';\n  static final _pattern = RegExp(_startDelimiter);\n\n  @override\n  RegExp get pattern => _pattern;\n\n  @override\n  void finalizeElement(Element element) {\n    element.children?.insert(0, Element.text('summary', 'Spoiler'));\n  }\n}\n\nclass _CenterBlockSyntax extends _DelimitedBlockSyntax {\n  const _CenterBlockSyntax()\n    : super(tag: 'center', startDelimiter: _delimiter, endDelimiter: _delimiter);\n\n  static const _delimiter = '~~~';\n  static final _pattern = RegExp(_delimiter);\n\n  @override\n  RegExp get pattern => _pattern;\n\n  @override\n  void finalizeElement(Element element) {}\n}\n\n/// AniList markdown treats content surrounded with \"~~~\" as centered,\n/// instead of code, so it should be excluded from this pattern.\nclass _FencedCodeBlockSyntax extends FencedCodeBlockSyntax {\n  const _FencedCodeBlockSyntax();\n\n  static final _pattern = RegExp(r'^([ ]{0,3})(?<backtick>`{3,})(?<backtickInfo>[^`]*)$');\n\n  @override\n  RegExp get pattern => _pattern;\n}\n\n/// AniList always accepts a line break, unlike standard markdown.\nclass _LineBreakSyntax extends InlineSyntax {\n  _LineBreakSyntax() : super(r'\\n', startCharacter: 10);\n\n  @override\n  bool onMatch(InlineParser parser, Match match) {\n    parser.addNode(Element.empty('br'));\n    return true;\n  }\n}\n\n/// Besides the standard markdown image syntax,\n/// AniList allows for an additional way to embed images.\nclass _ImageSyntax extends InlineSyntax {\n  _ImageSyntax() : super(r'img((?:\\d+%?)?)\\(((?:https:\\/\\/)[^)]+)\\)', caseSensitive: false);\n\n  @override\n  bool onMatch(InlineParser parser, Match match) {\n    parser.addNode(\n      Element.empty('img')\n        ..attributes['width'] = match.group(1)!\n        ..attributes['src'] = match.group(2)!,\n    );\n    return true;\n  }\n}\n\n/// YouTube videos are embedded with syntax different from other web videos.\nclass _YouTubeSyntax extends InlineSyntax {\n  _YouTubeSyntax()\n    : super(\n        r'youtube\\s?\\(\\s*(?:(?:https:\\/\\/)?(?:www\\.)?(?:(?:(?:music\\.)?youtube\\.com\\/watch\\?v=)|(?:youtu\\.be\\/)))?([^?&#)]+)(?:[^)]*)\\)',\n        caseSensitive: false,\n      );\n\n  @override\n  bool onMatch(InlineParser parser, Match match) {\n    parser.addNode(Element.text('youtube', match.group(1)!));\n    return true;\n  }\n}\n\nclass _VideoSyntax extends InlineSyntax {\n  _VideoSyntax() : super(r'webm\\(([^)]+)\\)', caseSensitive: false);\n\n  @override\n  bool onMatch(InlineParser parser, Match match) {\n    parser.addNode(\n      Element('video', [Element.empty('source')..attributes['src'] = match.group(1)!]),\n    );\n    return true;\n  }\n}\n\nclass _MentionSyntax extends InlineSyntax {\n  _MentionSyntax() : super(r'\\B@([A-Za-z0-9]+)', startCharacter: 64);\n\n  @override\n  bool onMatch(InlineParser parser, Match match) {\n    final name = match.group(1)!;\n    parser.addNode(\n      Element.text('a', '@$name')..attributes['href'] = 'https://anilist.co/user/$name',\n    );\n    return true;\n  }\n}\n"
  },
  {
    "path": "lib/util/paged.dart",
    "content": "/// Collection for pagination.\nclass Paged<T> {\n  const Paged({this.items = const [], this.hasNext = true, this.next = 1});\n\n  final List<T> items;\n\n  /// If there's another page to load.\n  final bool hasNext;\n\n  /// The index of the next page to be loaded.\n  final int next;\n\n  /// Recreate with another page loaded.\n  Paged<T> withNext(List<T> items, bool hasNext) =>\n      Paged(items: [...this.items, ...items], hasNext: hasNext, next: next + 1);\n}\n\nclass PagedWithTotal<T> extends Paged<T> {\n  const PagedWithTotal({super.items, super.hasNext, super.next, this.total = 0});\n\n  /// Count of all items, even the ones that aren't yet loaded.\n  final int total;\n\n  @override\n  PagedWithTotal<T> withNext(List<T> items, bool hasNext, [int? total]) => PagedWithTotal(\n    items: [...this.items, ...items],\n    hasNext: hasNext,\n    next: next + 1,\n    total: total ?? this.total,\n  );\n}\n"
  },
  {
    "path": "lib/util/paged_controller.dart",
    "content": "import 'package:flutter/widgets.dart';\n\n/// A [ScrollController] that can perform and action when\n/// the bottom of the page is reached. Used for pagination.\nclass PagedController extends ScrollController {\n  PagedController({required this.loadMore}) {\n    addListener(_listener);\n  }\n\n  /// The callback to call, when the end of the page is reached.\n  /// While it can be replaced, do so only if absolutely needed.\n  void Function() loadMore;\n\n  /// Keeps track of the last [position.maxScrollExtent].\n  /// Used to ensure that when the end of the page is reached,\n  /// only one call to [loadMore] is performed, at least until\n  /// the bottom of the newly expanded page is reached.\n  double _lastMaxExtent = 0;\n\n  /// When the user reaches the bottom, try loading more data.\n  void _listener() {\n    if (!hasClients) return;\n    if (positions.last.pixels < positions.last.maxScrollExtent - 100) return;\n    if (_lastMaxExtent == positions.last.maxScrollExtent) return;\n\n    _lastMaxExtent = positions.last.maxScrollExtent;\n    loadMore();\n  }\n\n  /// When a scrollable is detached, [_lastMaxExtent] needs to be reset, so\n  /// that it would work properly, if the scrollable gets attached again.\n  @override\n  void detach(ScrollPosition position) {\n    _lastMaxExtent = 0;\n    super.detach(position);\n  }\n}\n"
  },
  {
    "path": "lib/util/routes.dart",
    "content": "import 'dart:async';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/iterable_extension.dart';\nimport 'package:otraku/feature/activity/activities_model.dart';\nimport 'package:otraku/feature/comment/comment_view.dart';\nimport 'package:otraku/feature/forum/forum_view.dart';\nimport 'package:otraku/feature/thread/thread_view.dart';\nimport 'package:otraku/feature/viewer/persistence_provider.dart';\nimport 'package:otraku/feature/viewer/repository_provider.dart';\nimport 'package:otraku/widget/layout/top_bar.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/feature/activity/activities_view.dart';\nimport 'package:otraku/feature/activity/activity_view.dart';\nimport 'package:otraku/feature/calendar/calendar_view.dart';\nimport 'package:otraku/feature/character/character_view.dart';\nimport 'package:otraku/feature/collection/collection_view.dart';\nimport 'package:otraku/feature/favorites/favorites_view.dart';\nimport 'package:otraku/feature/home/home_model.dart';\nimport 'package:otraku/feature/home/home_view.dart';\nimport 'package:otraku/feature/media/media_view.dart';\nimport 'package:otraku/feature/notification/notifications_view.dart';\nimport 'package:otraku/feature/review/review_view.dart';\nimport 'package:otraku/feature/review/reviews_view.dart';\nimport 'package:otraku/feature/settings/settings_view.dart';\nimport 'package:otraku/feature/social/social_view.dart';\nimport 'package:otraku/feature/staff/staff_view.dart';\nimport 'package:otraku/feature/statistics/statistics_view.dart';\nimport 'package:otraku/feature/studio/studio_view.dart';\nimport 'package:otraku/feature/user/user_providers.dart';\nimport 'package:otraku/feature/user/user_view.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:url_launcher/url_launcher.dart';\n\nclass Routes {\n  const Routes._();\n\n  static const notFound = '/404';\n\n  static const settings = '/settings';\n\n  static const notifications = '/notifications';\n\n  static const calendar = '/calendar';\n\n  static String home([HomeTab? tab]) => '/home${tab != null ? \"?tab=${tab.name}\" : \"\"}';\n\n  static String media(int id, [String? imageUrl]) =>\n      '/media/$id${imageUrl != null ? \"?image=$imageUrl\" : \"\"}';\n\n  static String character(int id, [String? imageUrl]) =>\n      '/character/$id${imageUrl != null ? \"?image=$imageUrl\" : \"\"}';\n\n  static String staff(int id, [String? imageUrl]) =>\n      '/staff/$id${imageUrl != null ? \"?image=$imageUrl\" : \"\"}';\n\n  static String user(int id, [String? imageUrl]) =>\n      '/user/$id${imageUrl != null ? \"?image=$imageUrl\" : \"\"}';\n\n  static String userByName(String name, [String? imageUrl]) =>\n      '/user/$name${imageUrl != null ? \"?image=$imageUrl\" : \"\"}';\n\n  static String studio(int id, [String? name]) => '/studio/$id${name != null ? \"?name=$name\" : \"\"}';\n\n  static String review(int id, [String? imageUrl]) =>\n      '/review/$id${imageUrl != null ? \"?image=$imageUrl\" : \"\"}';\n\n  static String activity(int id, [ActivitiesTag? tag]) =>\n      '/activity/$id${tag != null ? \"?feed=${tag.toQueryParam()}\" : \"\"}';\n\n  static const forum = '/forum';\n\n  static String thread(int id) => '/thread/$id';\n\n  static String comment(int id) => '/comment/$id';\n\n  static String animeCollection(int id) => '/collection/anime/$id';\n\n  static String mangaCollection(int id) => '/collection/manga/$id';\n\n  static String activities(int id) => '/activities/$id';\n\n  static String favorites(int id) => '/favorites/$id';\n\n  static String social(int id) => '/social/$id';\n\n  static String reviews(int id) => '/reviews/$id';\n\n  static String statistics(int id) => '/statistics/$id';\n\n  static GoRouter buildRouter(bool Function() mustConfirmExit) {\n    final onExit = (BuildContext context, GoRouterState _) async {\n      if (!mustConfirmExit()) return Future.value(true);\n\n      var exit = false;\n      await ConfirmationDialog.show(\n        context,\n        title: 'Exit?',\n        primaryAction: 'Yes',\n        secondaryAction: 'No',\n        onConfirm: () => exit = true,\n      );\n\n      return exit;\n    };\n\n    final routes = [\n      GoRoute(path: '/', redirect: (context, state) => '/home'),\n      GoRoute(\n        path: '/auth',\n        builder: (context, state) {\n          final fragment = state.uri.fragment;\n          if (fragment.isEmpty) return const _AuthView(null);\n\n          final start = fragment.indexOf('=') + 1;\n          final middle = fragment.indexOf('&');\n          final end = fragment.lastIndexOf('=') + 1;\n\n          final token = fragment.substring(start, middle);\n          final expiration = int.tryParse(fragment.substring(end)) ?? -1;\n          if (token.isEmpty || expiration <= 0) return const _AuthView(null);\n\n          return _AuthView((token, expiration));\n        },\n      ),\n      GoRoute(path: '/404', builder: (context, state) => const NotFoundView()),\n      GoRoute(\n        path: '/home',\n        onExit: onExit,\n        redirect: (context, state) {\n          final tabName = state.uri.queryParameters['tab'];\n          if (tabName == null) return null;\n\n          final tab = HomeTab.values.firstWhereOrNull((e) => e.name == tabName);\n          return tab != null ? null : notFound;\n        },\n        builder: (context, state) {\n          final tabName = state.uri.queryParameters['tab'];\n\n          return HomeView(\n            key: state.pageKey,\n            tab: tabName != null ? HomeTab.values.byName(tabName) : null,\n          );\n        },\n      ),\n      GoRoute(path: '/settings', builder: (context, state) => const SettingsView()),\n      GoRoute(path: '/notifications', builder: (context, state) => const NotificationsView()),\n      GoRoute(path: '/calendar', builder: (context, state) => const CalendarView()),\n      GoRoute(\n        path: '/media/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) =>\n            MediaView(int.parse(state.pathParameters['id']!), state.uri.queryParameters['image']),\n      ),\n      GoRoute(\n        path: '/character/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) => CharacterView(\n          int.parse(state.pathParameters['id']!),\n          state.uri.queryParameters['image'],\n        ),\n      ),\n      GoRoute(\n        path: '/staff/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) =>\n            StaffView(int.parse(state.pathParameters['id']!), state.uri.queryParameters['image']),\n      ),\n      GoRoute(\n        path: '/user/:idOrName',\n        builder: (context, state) {\n          final param = state.pathParameters['idOrName']!;\n          final id = int.tryParse(param);\n          final tag = id != null ? idUserTag(id) : nameUserTag(param);\n          return UserView(tag, state.uri.queryParameters['image']);\n        },\n      ),\n      GoRoute(\n        path: '/studio/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) =>\n            StudioView(int.parse(state.pathParameters['id']!), state.uri.queryParameters['name']),\n      ),\n      GoRoute(\n        path: '/review/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) =>\n            ReviewView(int.parse(state.pathParameters['id']!), state.uri.queryParameters['image']),\n      ),\n      GoRoute(\n        path: '/activity/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) => ActivityView(\n          int.parse(state.pathParameters['id']!),\n          ActivitiesTag.fromQueryParam(state.uri.queryParameters['feed'] ?? ''),\n        ),\n      ),\n      GoRoute(path: '/forum', builder: (context, state) => const ForumView()),\n      GoRoute(\n        path: '/thread/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) => ThreadView(int.parse(state.pathParameters['id']!)),\n      ),\n      GoRoute(\n        path: '/comment/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) => CommentView(int.parse(state.pathParameters['id']!)),\n      ),\n      GoRoute(\n        path: '/collection/anime/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) => CollectionView(int.parse(state.pathParameters['id']!), true),\n      ),\n      GoRoute(\n        path: '/collection/manga/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) => CollectionView(int.parse(state.pathParameters['id']!), false),\n      ),\n      GoRoute(\n        path: '/activities/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) {\n          final userId = int.parse(state.pathParameters['id']!);\n          return ActivitiesView(UserActivitiesTag(userId));\n        },\n      ),\n      GoRoute(\n        path: '/favorites/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) => FavoritesView(int.parse(state.pathParameters['id']!)),\n      ),\n      GoRoute(\n        path: '/social/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) => SocialView(int.parse(state.pathParameters['id']!)),\n      ),\n      GoRoute(\n        path: '/reviews/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) => ReviewsView(int.parse(state.pathParameters['id']!)),\n      ),\n      GoRoute(\n        path: '/statistics/:id',\n        redirect: _parseIdOr404,\n        builder: (context, state) => StatisticsView(int.parse(state.pathParameters['id']!)),\n      ),\n\n      // Extra routes for AniList deep links:\n      // - Media endpoints are split between anime/manga.\n      // - Comments are thread sub-routes and threads are forum sub-routes.\n      // - Paths can contain superfluous information after the path parameter.\n      GoRoute(\n        path: '/anime/:id',\n        redirect: (context, state) => '/media/${state.pathParameters['id']}',\n      ),\n      GoRoute(\n        path: '/manga/:id',\n        redirect: (context, state) => '/media/${state.pathParameters['id']}',\n      ),\n      GoRoute(\n        path: '/anime/:id/:_(.*)',\n        redirect: (context, state) => '/media/${state.pathParameters['id']}',\n      ),\n      GoRoute(\n        path: '/manga/:id/:_(.*)',\n        redirect: (context, state) => '/media/${state.pathParameters['id']}',\n      ),\n      GoRoute(\n        path: '/character/:id/:_(.*)',\n        redirect: (context, state) => '/character/${state.pathParameters['id']}',\n      ),\n      GoRoute(\n        path: '/staff/:id/:_(.*)',\n        redirect: (context, state) => '/staff/${state.pathParameters['id']}',\n      ),\n      GoRoute(\n        path: '/studio/:id/:_(.*)',\n        redirect: (context, state) => '/studio/${state.pathParameters['id']}',\n      ),\n      GoRoute(\n        path: '/user/:name/:_(.*)',\n        redirect: (context, state) => '/user/${state.pathParameters['name']}',\n      ),\n      GoRoute(\n        path: '/forum/thread/:_([^/]*)/comment/:id',\n        redirect: (context, state) => '/comment/${state.pathParameters['id']}',\n      ),\n      GoRoute(\n        path: '/forum/thread/:id',\n        redirect: (context, state) => '/thread/${state.pathParameters['id']}',\n      ),\n      GoRoute(path: '/forum/:_([^/]*)', redirect: (context, state) => '/forum'),\n    ];\n\n    return GoRouter(\n      routes: routes,\n      initialLocation: Routes.home(),\n      errorBuilder: (context, state) => const NotFoundView(),\n    );\n  }\n}\n\nString? _parseIdOr404(BuildContext context, GoRouterState state) =>\n    int.tryParse(state.pathParameters['id'] ?? '') == null ? Routes.notFound : null;\n\nclass NotFoundView extends StatelessWidget {\n  const NotFoundView();\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: const TopBar(title: 'Not Found'),\n      body: Center(\n        child: Column(\n          mainAxisSize: .min,\n          children: [\n            Text('404 Not Found', style: TextTheme.of(context).bodyMedium),\n            TextButton(child: const Text('Go Home'), onPressed: () => context.go(Routes.home())),\n          ],\n        ),\n      ),\n    );\n  }\n}\n\nclass _AuthView extends ConsumerStatefulWidget {\n  const _AuthView(this.credentials);\n\n  final (String token, int secondsUntilExpiration)? credentials;\n\n  @override\n  ConsumerState<_AuthView> createState() => __AuthViewState();\n}\n\nclass __AuthViewState extends ConsumerState<_AuthView> {\n  @override\n  void initState() {\n    super.initState();\n\n    // On iOS the in app browser doesn't automatically close after login.\n    closeInAppWebView().onError((_, _) {});\n\n    if (widget.credentials == null) {\n      WidgetsBinding.instance.addPostFrameCallback((_) async {\n        await ConfirmationDialog.show(context, title: 'Invalid credentials');\n        if (mounted) context.go(Routes.home());\n      });\n    }\n\n    _attemptToFinishAccountSetup();\n  }\n\n  @override\n  void didUpdateWidget(covariant _AuthView oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    if (widget.credentials?.$1 != oldWidget.credentials?.$1 ||\n        widget.credentials?.$2 != oldWidget.credentials?.$2) {\n      _attemptToFinishAccountSetup();\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      body: const Center(\n        child: Column(\n          mainAxisSize: .min,\n          children: [Text('Authenticating, please wait...'), SizedBox(height: 20), Loader()],\n        ),\n      ),\n    );\n  }\n\n  void _attemptToFinishAccountSetup() async {\n    if (widget.credentials == null) {\n      return;\n    }\n\n    final token = widget.credentials!.$1;\n    final expiration = widget.credentials!.$2;\n\n    final account = await ref.read(repositoryProvider.notifier).initAccount(token, expiration);\n\n    if (account == null) {\n      if (mounted) {\n        await ConfirmationDialog.show(context, title: 'Failed to connect account');\n\n        if (mounted) context.go(Routes.home());\n      }\n\n      return;\n    }\n\n    await ref.read(persistenceProvider.notifier).addAccount(account);\n    if (mounted) context.go(Routes.home());\n  }\n}\n"
  },
  {
    "path": "lib/util/theming.dart",
    "content": "import 'dart:ui';\n\nimport 'package:flutter/material.dart';\n\nenum FormFactor { phone, tablet }\n\nenum ThemeBase {\n  navy('Navy', Color(0xFF45A0F2)),\n  mint('Mint', Color(0xFF2AB8B8)),\n  lavender('Lavender', Color(0xFFB4ABF5)),\n  caramel('Caramel', Color(0xFFF78204)),\n  forest('Forest', Color(0xFF00FFA9)),\n  wine('Wine', Color(0xFF894771)),\n  mustard('Mustard', Color(0xFFFFBF02));\n\n  const ThemeBase(this.title, this.seed);\n\n  final String title;\n  final Color seed;\n}\n\nclass Theming extends ThemeExtension<Theming> {\n  const Theming({required this.formFactor, required this.rightButtonOrientation});\n\n  /// Pages should adapt their layouts, in consideration of the [formFactor].\n  final FormFactor formFactor;\n\n  /// Determines whether FAB and prominent buttons should be on the right side,\n  /// with lest important buttons on the left.\n  /// This makes core actions more accessible.\n  final bool rightButtonOrientation;\n\n  static Theming of(BuildContext context) =>\n      Theme.of(context).extension<Theming>() ??\n      const Theming(formFactor: .phone, rightButtonOrientation: true);\n\n  @override\n  ThemeExtension<Theming> copyWith({FormFactor? formFactor, bool? rightButtonOrientation}) =>\n      Theming(\n        formFactor: formFactor ?? this.formFactor,\n        rightButtonOrientation: rightButtonOrientation ?? this.rightButtonOrientation,\n      );\n\n  @override\n  ThemeExtension<Theming> lerp(covariant ThemeExtension<Theming>? other, double t) =>\n      switch (other) {\n        Theming _ => other,\n        _ => this,\n      };\n\n  static const windowWidthMedium = 600.0;\n  static const windowWidthLarge = 840.0;\n\n  static const offset = 10.0;\n  static const minTapTarget = 48.0;\n  static const normalTapTarget = 56.0;\n  static const coverHtoWRatio = 1.53;\n\n  static const fontBig = 18.0;\n  static const fontMedium = 15.0;\n  static const fontSmall = 13.0;\n\n  static const iconBig = 25.0;\n  static const iconSmall = 20.0;\n\n  static const paddingAll = EdgeInsets.all(offset);\n  static const radiusSmall = Radius.circular(12);\n  static const radiusBig = Radius.circular(24);\n  static const borderRadiusSmall = BorderRadius.all(radiusSmall);\n  static const borderRadiusBig = BorderRadius.all(radiusBig);\n  static final blurFilter = ImageFilter.blur(sigmaX: 5, sigmaY: 5);\n  static const bouncyPhysics = AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics());\n\n  static ThemeData generateThemeData(ColorScheme scheme) => ThemeData(\n    fontFamily: 'Rubik',\n    colorScheme: scheme,\n    scaffoldBackgroundColor: scheme.surface,\n    disabledColor: scheme.surface,\n    unselectedWidgetColor: scheme.surface,\n    highlightColor: Colors.transparent,\n    cardTheme: const CardThemeData(margin: .all(0)),\n    iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: iconBig),\n    navigationBarTheme: NavigationBarThemeData(\n      backgroundColor: scheme.surface.withAlpha(190),\n      labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,\n    ),\n    navigationRailTheme: const NavigationRailThemeData(\n      labelType: NavigationRailLabelType.all,\n      groupAlignment: 0,\n    ),\n    chipTheme: ChipThemeData(\n      labelStyle: TextStyle(\n        color: scheme.onSecondaryContainer,\n        fontVariations: const [FontVariation('wght', 400)],\n      ),\n    ),\n    segmentedButtonTheme: const SegmentedButtonThemeData(\n      style: ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),\n    ),\n    sliderTheme: const SliderThemeData(\n      trackGap: 6,\n      trackHeight: 16,\n      trackShape: GappedSliderTrackShape(),\n      thumbShape: HandleThumbShape(),\n      thumbSize: WidgetStatePropertyAll(Size(4, 44)),\n    ),\n    elevatedButtonTheme: ElevatedButtonThemeData(\n      style: ElevatedButton.styleFrom(\n        backgroundColor: scheme.primary,\n        foregroundColor: scheme.onPrimary,\n        iconColor: scheme.onPrimary,\n        textStyle: const TextStyle(\n          fontSize: fontMedium,\n          fontVariations: [FontVariation('wght', 500)],\n        ),\n      ),\n    ),\n    filledButtonTheme: FilledButtonThemeData(\n      style: FilledButton.styleFrom(\n        textStyle: const TextStyle(\n          fontSize: fontMedium,\n          fontVariations: [FontVariation('wght', 400)],\n        ),\n      ),\n    ),\n    textButtonTheme: TextButtonThemeData(\n      style: TextButton.styleFrom(\n        textStyle: const TextStyle(\n          fontSize: fontMedium,\n          fontVariations: [FontVariation('wght', 450)],\n        ),\n      ),\n    ),\n    listTileTheme: ListTileThemeData(\n      contentPadding: const .symmetric(horizontal: offset),\n      titleTextStyle: TextStyle(\n        fontSize: fontMedium,\n        color: scheme.onSurface,\n        fontVariations: const [FontVariation('wght', 400)],\n      ),\n      subtitleTextStyle: TextStyle(\n        fontSize: fontSmall,\n        color: scheme.onSurfaceVariant,\n        fontVariations: const [FontVariation('wght', 350)],\n      ),\n    ),\n    textTheme: TextTheme(\n      titleMedium: TextStyle(\n        fontSize: fontBig,\n        color: scheme.onSurface,\n        fontVariations: const [FontVariation('wght', 450)],\n      ),\n      titleSmall: TextStyle(\n        fontSize: fontMedium,\n        color: scheme.onSurface,\n        fontVariations: const [FontVariation('wght', 450)],\n      ),\n      bodyLarge: TextStyle(\n        fontSize: fontBig,\n        color: scheme.onSurface,\n        fontVariations: const [FontVariation('wght', 400)],\n      ),\n      bodyMedium: TextStyle(\n        fontSize: fontMedium,\n        color: scheme.onSurface,\n        fontVariations: const [FontVariation('wght', 400)],\n      ),\n      labelLarge: TextStyle(\n        fontSize: fontMedium,\n        color: scheme.onSurfaceVariant,\n        fontVariations: const [FontVariation('wght', 400)],\n      ),\n      labelMedium: TextStyle(\n        fontSize: fontMedium,\n        color: scheme.onSurfaceVariant,\n        fontVariations: const [FontVariation('wght', 400)],\n      ),\n      labelSmall: TextStyle(\n        fontSize: fontSmall,\n        color: scheme.onSurfaceVariant,\n        fontVariations: const [FontVariation('wght', 350)],\n        letterSpacing: 0.5,\n      ),\n    ),\n    textSelectionTheme: TextSelectionThemeData(\n      cursorColor: scheme.primary,\n      selectionHandleColor: scheme.primary,\n      selectionColor: scheme.primary.withAlpha(50),\n    ),\n    dividerTheme: const DividerThemeData(thickness: 1),\n    dialogTheme: DialogThemeData(\n      backgroundColor: scheme.surface,\n      titleTextStyle: TextStyle(\n        fontSize: fontMedium,\n        color: scheme.onSurface,\n        fontVariations: const [FontVariation('wght', 500)],\n      ),\n      contentTextStyle: TextStyle(\n        fontSize: fontMedium,\n        color: scheme.onSurface,\n        fontVariations: const [FontVariation('wght', 400)],\n      ),\n    ),\n    tooltipTheme: TooltipThemeData(\n      padding: paddingAll,\n      textStyle: TextStyle(color: scheme.onSurfaceVariant),\n      decoration: BoxDecoration(\n        color: scheme.surfaceContainerHighest,\n        borderRadius: borderRadiusSmall,\n        border: .all(color: scheme.outline),\n        boxShadow: [BoxShadow(color: scheme.surface, blurRadius: 10)],\n      ),\n    ),\n    scrollbarTheme: ScrollbarThemeData(\n      interactive: true,\n      radius: radiusSmall,\n      thickness: .all(5),\n      thumbColor: .all(scheme.primary),\n    ),\n    inputDecorationTheme: InputDecorationTheme(\n      isDense: true,\n      hintStyle: TextStyle(\n        fontSize: fontMedium,\n        color: scheme.onSurfaceVariant,\n        fontVariations: const [FontVariation('wght', 400)],\n      ),\n      border: const OutlineInputBorder(\n        borderRadius: borderRadiusSmall,\n        borderSide: BorderSide.none,\n      ),\n    ),\n  );\n}\n"
  },
  {
    "path": "lib/util/tile_modelable.dart",
    "content": "/// A lot of models have commonly accessed elements\n/// that can be unified and used in agnostic views.\nabstract class TileModelable {\n  int get tileId;\n  String get tileTitle;\n  String? get tileSubtitle;\n  String get tileImageUrl;\n}\n"
  },
  {
    "path": "lib/widget/cached_image.dart",
    "content": "import 'package:cached_network_image/cached_network_image.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter_cache_manager/flutter_cache_manager.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\n\n/// A custom cache manager is needed to define exact image cap and stale period.\nfinal _cacheManager = CacheManager(\n  Config('imageCache', maxNrOfCacheObjects: 1000, stalePeriod: const Duration(days: 10)),\n);\n\n/// Erases image cache.\nvoid clearImageCache() => _cacheManager.emptyCache();\n\n/// A [CachedNetworkImage] wrapper that simplifies the interface\n/// and uses the custom cache manager, without exposing it.\nclass CachedImage extends StatelessWidget {\n  const CachedImage(\n    this.imageUrl, {\n    this.fit = BoxFit.cover,\n    this.width = double.infinity,\n    this.height = double.infinity,\n  });\n\n  final String imageUrl;\n  final BoxFit fit;\n  final double? width;\n  final double? height;\n\n  @override\n  Widget build(BuildContext context) {\n    return CachedNetworkImage(\n      imageUrl: imageUrl,\n      fit: fit,\n      width: width,\n      height: height,\n      cacheManager: _cacheManager,\n      fadeInDuration: const Duration(milliseconds: 300),\n      fadeOutDuration: const Duration(milliseconds: 300),\n      errorWidget: (context, _, _) => IconButton(\n        tooltip: 'Error',\n        icon: const Icon(Icons.close_outlined),\n        onPressed: () => SnackBarExtension.show(context, 'Failed to load image'),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/dialogs.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/html_content.dart';\nimport 'package:vector_math/vector_math_64.dart' show Vector3;\n\nclass TextInputDialog extends StatefulWidget {\n  const TextInputDialog({required this.title, required this.initialValue, this.validator});\n\n  final String title;\n  final String initialValue;\n  final String? Function(String)? validator;\n\n  @override\n  State<TextInputDialog> createState() => _TextInputDialogState();\n}\n\nclass _TextInputDialogState extends State<TextInputDialog> {\n  late final _textCtrl = TextEditingController(text: widget.initialValue);\n  final _formKey = GlobalKey<FormState>();\n\n  @override\n  void dispose() {\n    _textCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return AlertDialog(\n      title: Text(widget.title),\n      content: Form(\n        key: _formKey,\n        child: TextFormField(\n          autofocus: true,\n          controller: _textCtrl,\n          decoration: InputDecoration(\n            isDense: true,\n            hint: const Text('Enter'),\n            hintStyle: TextStyle(color: ColorScheme.of(context).onSurfaceVariant),\n            border: const OutlineInputBorder(borderRadius: Theming.borderRadiusSmall),\n          ),\n          autovalidateMode: AutovalidateMode.onUserInteraction,\n          validator: (value) {\n            final text = value?.trim() ?? '';\n            if (text.isEmpty) {\n              return 'The field cannot be empty.';\n            }\n\n            if (widget.validator != null) {\n              return widget.validator!(text);\n            }\n\n            return null;\n          },\n        ),\n      ),\n      actions: [\n        TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),\n        TextButton(\n          onPressed: () {\n            if (_formKey.currentState!.validate()) {\n              Navigator.pop(context, _textCtrl.text.trim());\n            }\n          },\n          child: const Text('Confirm'),\n        ),\n      ],\n    );\n  }\n}\n\n// A basic container for a dialog.\nclass DialogBox extends StatelessWidget {\n  const DialogBox(this.child);\n\n  final Widget child;\n\n  @override\n  Widget build(BuildContext context) {\n    return Dialog(\n      insetPadding: const .symmetric(horizontal: 30, vertical: 50),\n      child: ConstrainedBox(\n        constraints: const BoxConstraints(maxWidth: 700, maxHeight: 600),\n        child: child,\n      ),\n    );\n  }\n}\n\nclass ConfirmationDialog extends StatelessWidget {\n  const ConfirmationDialog._({\n    required this.title,\n    required this.content,\n    required this.primaryAction,\n    required this.secondaryAction,\n  });\n\n  final String title;\n  final String? content;\n  final String primaryAction;\n  final String? secondaryAction;\n\n  static Future<void> show(\n    BuildContext context, {\n    required String title,\n    String? content,\n    String primaryAction = 'Ok',\n    String? secondaryAction,\n    void Function()? onConfirm,\n  }) => showDialog(\n    context: context,\n    builder: (context) => ConfirmationDialog._(\n      title: title,\n      content: content,\n      primaryAction: primaryAction,\n      secondaryAction: secondaryAction,\n    ),\n  ).then((ok) => ok == true ? onConfirm?.call() : null);\n\n  @override\n  Widget build(BuildContext context) {\n    return AlertDialog(\n      title: Text(title),\n      content: content != null ? Text(content!) : null,\n      actions: [\n        if (secondaryAction != null)\n          TextButton(child: Text(secondaryAction!), onPressed: () => Navigator.pop(context, false)),\n        TextButton(child: Text(primaryAction), onPressed: () => Navigator.pop(context, true)),\n      ],\n    );\n  }\n}\n\nclass ImageDialog extends StatefulWidget {\n  const ImageDialog(this.url);\n\n  final String url;\n\n  @override\n  State<ImageDialog> createState() => _ImageDialogState();\n}\n\nclass _ImageDialogState extends State<ImageDialog> with SingleTickerProviderStateMixin {\n  final _transformCtrl = TransformationController();\n  late final AnimationController _animationCtrl;\n  late final CurvedAnimation _curveWrapper;\n  Animation<Matrix4>? _animation;\n\n  /// Last place the user double-tapped on.\n  Offset? _lastOffset;\n\n  @override\n  void initState() {\n    super.initState();\n    _animationCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));\n    _curveWrapper = CurvedAnimation(parent: _animationCtrl, curve: Curves.easeOutExpo);\n  }\n\n  @override\n  void dispose() {\n    _transformCtrl.dispose();\n    _animationCtrl.dispose();\n    super.dispose();\n  }\n\n  void _updateState() => _transformCtrl.value = _animation!.value;\n\n  void _endAnimation() {\n    _animation?.removeListener(_updateState);\n    _animation = null;\n    _animationCtrl.reset();\n  }\n\n  void _animateMatrixTo(Matrix4 goal) {\n    _endAnimation();\n    _animation = Matrix4Tween(begin: _transformCtrl.value, end: goal).animate(_curveWrapper);\n    _animation!.addListener(_updateState);\n    _animationCtrl.forward();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return Dialog(\n      insetPadding: .zero,\n      backgroundColor: Colors.transparent,\n      surfaceTintColor: Colors.transparent,\n      child: GestureDetector(\n        onDoubleTapDown: (details) => _lastOffset = details.localPosition,\n        onDoubleTap: () {\n          // If zoomed in, zoom out.\n          if (_transformCtrl.value.getMaxScaleOnAxis() > 1) {\n            _animateMatrixTo(Matrix4.identity());\n            return;\n          }\n\n          // Can't be null, but checking just in case.\n          if (_lastOffset == null) return;\n\n          // If zoomed out, zoom in towards the tapped spot.\n          final zoomed = _transformCtrl.value.clone();\n          zoomed.translateByVector3(Vector3(-_lastOffset!.dx, -_lastOffset!.dy, 0));\n          zoomed.scaleByVector3(Vector3(2.0, 2.0, 1.0));\n          _animateMatrixTo(zoomed);\n        },\n        child: InteractiveViewer(\n          clipBehavior: Clip.none,\n          transformationController: _transformCtrl,\n          child: CachedImage(widget.url, fit: BoxFit.contain, width: null, height: null),\n        ),\n      ),\n    );\n  }\n}\n\nclass TextDialog extends StatelessWidget {\n  const TextDialog({required this.title, required this.text});\n\n  final String title;\n  final String text;\n\n  @override\n  Widget build(BuildContext context) => _DialogColumn(title: title, child: SelectableText(text));\n}\n\nclass HtmlDialog extends StatelessWidget {\n  const HtmlDialog({required this.title, required this.text});\n\n  final String title;\n  final String text;\n\n  @override\n  Widget build(BuildContext context) => _DialogColumn(title: title, child: HtmlContent(text));\n}\n\nclass _DialogColumn extends StatelessWidget {\n  const _DialogColumn({required this.title, required this.child});\n\n  final String title;\n  final Widget child;\n\n  @override\n  Widget build(BuildContext context) {\n    return DialogBox(\n      Padding(\n        padding: const .symmetric(horizontal: 20),\n        child: Column(\n          crossAxisAlignment: .start,\n          mainAxisSize: .min,\n          children: [\n            Padding(\n              padding: const .symmetric(vertical: Theming.offset),\n              child: Text(title, style: TextTheme.of(context).bodyMedium),\n            ),\n            const Divider(height: 2, thickness: 2),\n            Flexible(\n              fit: FlexFit.loose,\n              child: Scrollbar(\n                child: SingleChildScrollView(\n                  padding: const .symmetric(vertical: Theming.offset),\n                  child: child,\n                ),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/grid/chip_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/util/theming.dart';\n\nclass ChipGrid extends StatelessWidget {\n  const ChipGrid({\n    required this.title,\n    required this.placeholder,\n    required this.children,\n    required this.onEdit,\n    this.onClear,\n  });\n\n  final String title;\n  final String placeholder;\n  final List<Widget> children;\n  final void Function() onEdit;\n  final void Function()? onClear;\n\n  @override\n  Widget build(BuildContext context) {\n    return Column(\n      mainAxisSize: .min,\n      children: [\n        Row(\n          children: [\n            Text(title),\n            const Spacer(),\n            if (onClear != null && children.isNotEmpty)\n              SizedBox(\n                height: 35,\n                child: IconButton(\n                  key: const ValueKey('Clear'),\n                  icon: const Icon(Ionicons.close_outline),\n                  tooltip: 'Clear',\n                  onPressed: onClear!,\n                  color: ColorScheme.of(context).onSurface,\n                  padding: const .symmetric(horizontal: Theming.offset),\n                ),\n              ),\n            SizedBox(\n              height: 35,\n              child: IconButton(\n                icon: const Icon(Ionicons.add_circle_outline),\n                tooltip: 'Edit',\n                onPressed: onEdit,\n                color: ColorScheme.of(context).onSurface,\n                padding: const .symmetric(horizontal: Theming.offset),\n              ),\n            ),\n          ],\n        ),\n        children.isNotEmpty\n            ? Wrap(spacing: 5, children: children)\n            : SizedBox(\n                height: Theming.minTapTarget,\n                child: Center(\n                  child: Text('No $placeholder', style: TextTheme.of(context).labelMedium),\n                ),\n              ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/grid/dual_relation_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/util/tile_modelable.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\n\nclass DualRelationGrid extends StatelessWidget {\n  const DualRelationGrid({\n    required this.items,\n    required this.onTapPrimary,\n    required this.onTapSecondary,\n    required this.highContrast,\n  });\n\n  final List<(TileModelable, TileModelable?)> items;\n  final void Function(TileModelable item) onTapPrimary;\n  final void Function(TileModelable item) onTapSecondary;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    if (items.isEmpty) return const SliverToBoxAdapter();\n\n    final textTheme = TextTheme.of(context);\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelSmallLineHeight = context.lineHeight(textTheme.labelSmall!);\n    final tileHeight = bodyMediumLineHeight * 3 + labelSmallLineHeight * 2 + 13;\n    final imageWidth = tileHeight / Theming.coverHtoWRatio;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 300, height: tileHeight),\n      delegate: SliverChildBuilderDelegate(\n        childCount: items.length,\n        (context, i) => _Tile(\n          primaryItem: items[i].$1,\n          secondaryItem: items[i].$2,\n          onTapPrimary: onTapPrimary,\n          onTapSecondary: onTapSecondary,\n          highContrast: highContrast,\n          imageWidth: imageWidth,\n        ),\n      ),\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile({\n    required this.primaryItem,\n    required this.secondaryItem,\n    required this.onTapPrimary,\n    required this.onTapSecondary,\n    required this.highContrast,\n    required this.imageWidth,\n  });\n\n  final TileModelable primaryItem;\n  final TileModelable? secondaryItem;\n  final void Function(TileModelable item) onTapPrimary;\n  final void Function(TileModelable item) onTapSecondary;\n  final bool highContrast;\n  final double imageWidth;\n\n  @override\n  Widget build(BuildContext context) {\n    late final Widget centerContent;\n    if (secondaryItem != null) {\n      centerContent = Column(\n        crossAxisAlignment: .stretch,\n        mainAxisAlignment: .spaceBetween,\n        children: [\n          Flexible(\n            child: GestureDetector(\n              behavior: .opaque,\n              onTap: () => onTapPrimary(primaryItem),\n              child: Column(\n                mainAxisSize: .min,\n                crossAxisAlignment: .start,\n                children: [\n                  Flexible(child: Text(primaryItem.tileTitle, overflow: .ellipsis, maxLines: 2)),\n                  if (primaryItem.tileSubtitle != null)\n                    Text(\n                      primaryItem.tileSubtitle!,\n                      style: TextTheme.of(context).labelSmall,\n                      overflow: .ellipsis,\n                      maxLines: 1,\n                    ),\n                ],\n              ),\n            ),\n          ),\n          const Divider(height: 3),\n          GestureDetector(\n            behavior: .opaque,\n            onTap: () => onTapSecondary(secondaryItem!),\n            child: Column(\n              mainAxisSize: .min,\n              crossAxisAlignment: .end,\n              children: [\n                Flexible(\n                  child: Text(\n                    secondaryItem!.tileTitle,\n                    overflow: .ellipsis,\n                    textAlign: .end,\n                    maxLines: 1,\n                  ),\n                ),\n                if (secondaryItem!.tileSubtitle != null)\n                  Text(\n                    secondaryItem!.tileSubtitle!,\n                    style: TextTheme.of(context).labelSmall,\n                    overflow: .ellipsis,\n                    maxLines: 1,\n                  ),\n              ],\n            ),\n          ),\n        ],\n      );\n    } else {\n      centerContent = GestureDetector(\n        behavior: .opaque,\n        onTap: () => onTapPrimary(primaryItem),\n        child: Column(\n          mainAxisAlignment: .start,\n          crossAxisAlignment: .start,\n          children: [\n            Flexible(child: Text(primaryItem.tileTitle, overflow: .ellipsis, maxLines: 2)),\n            if (primaryItem.tileSubtitle != null)\n              Text(\n                primaryItem.tileSubtitle!,\n                style: TextTheme.of(context).labelSmall,\n                overflow: .ellipsis,\n                maxLines: 2,\n              ),\n          ],\n        ),\n      );\n    }\n\n    return CardExtension.highContrast(highContrast)(\n      child: Row(\n        children: [\n          GestureDetector(\n            behavior: .opaque,\n            onTap: () => onTapPrimary(primaryItem),\n            child: ClipRRect(\n              borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n              child: CachedImage(primaryItem.tileImageUrl, width: imageWidth),\n            ),\n          ),\n          Expanded(\n            child: Padding(\n              padding: const .symmetric(horizontal: Theming.offset, vertical: 5),\n              child: centerContent,\n            ),\n          ),\n          if (secondaryItem != null)\n            GestureDetector(\n              behavior: .opaque,\n              key: ValueKey(secondaryItem!.tileId),\n              onTap: () => onTapSecondary(secondaryItem!),\n              child: ClipRRect(\n                borderRadius: const BorderRadius.horizontal(right: Theming.radiusSmall),\n                child: CachedImage(secondaryItem!.tileImageUrl, width: imageWidth),\n              ),\n            ),\n        ],\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/grid/mono_relation_grid.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/util/tile_modelable.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/grid/sliver_grid_delegates.dart';\n\nclass MonoRelationGrid extends StatelessWidget {\n  const MonoRelationGrid({required this.items, required this.onTap, required this.highContrast});\n\n  final List<TileModelable> items;\n  final void Function(TileModelable item) onTap;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    if (items.isEmpty) return const SliverToBoxAdapter();\n\n    final textTheme = TextTheme.of(context);\n    final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);\n    final labelSmallLineHeight = context.lineHeight(textTheme.labelSmall!);\n    final tileHeight = bodyMediumLineHeight * 2 + labelSmallLineHeight * 2 + 10;\n    final imageWidth = tileHeight / Theming.coverHtoWRatio;\n\n    return SliverGrid(\n      gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 240, height: tileHeight),\n      delegate: SliverChildBuilderDelegate(\n        childCount: items.length,\n        (context, i) =>\n            _Tile(item: items[i], onTap: onTap, highContrast: highContrast, imageWidth: imageWidth),\n      ),\n    );\n  }\n}\n\nclass _Tile extends StatelessWidget {\n  const _Tile({\n    required this.item,\n    required this.onTap,\n    required this.highContrast,\n    required this.imageWidth,\n  });\n\n  final TileModelable item;\n  final void Function(TileModelable item) onTap;\n  final bool highContrast;\n  final double imageWidth;\n\n  @override\n  Widget build(BuildContext context) {\n    return CardExtension.highContrast(highContrast)(\n      child: InkWell(\n        borderRadius: Theming.borderRadiusSmall,\n        onTap: () => onTap(item),\n        child: Row(\n          children: [\n            ClipRRect(\n              borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),\n              child: CachedImage(item.tileImageUrl, width: imageWidth),\n            ),\n            Expanded(\n              child: Padding(\n                padding: const .symmetric(horizontal: Theming.offset, vertical: 5),\n                child: Column(\n                  mainAxisAlignment: .spaceEvenly,\n                  crossAxisAlignment: .start,\n                  children: [\n                    Flexible(child: Text(item.tileTitle, overflow: .ellipsis, maxLines: 2)),\n                    if (item.tileSubtitle != null)\n                      Text(\n                        item.tileSubtitle!,\n                        style: TextTheme.of(context).labelSmall,\n                        overflow: .ellipsis,\n                        maxLines: 2,\n                      ),\n                  ],\n                ),\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/grid/sliver_grid_delegates.dart",
    "content": "import 'package:flutter/rendering.dart';\nimport 'package:otraku/util/theming.dart';\n\n/// Places as many items on the cross axis as possible, without making them\n/// narrower than [minWidth]. The item height is fixed.\nclass SliverGridDelegateWithMinWidthAndFixedHeight extends SliverGridDelegate {\n  const SliverGridDelegateWithMinWidthAndFixedHeight({\n    required this.minWidth,\n    required this.height,\n    this.mainAxisSpacing = Theming.offset,\n    this.crossAxisSpacing = Theming.offset,\n  }) : assert(minWidth > 0),\n       assert(height > 0),\n       assert(mainAxisSpacing >= 0),\n       assert(crossAxisSpacing >= 0);\n\n  final double minWidth;\n  final double height;\n  final double mainAxisSpacing;\n  final double crossAxisSpacing;\n\n  bool _debugAssertIsValid() {\n    assert(minWidth > 0.0);\n    assert(mainAxisSpacing >= 0.0);\n    assert(crossAxisSpacing >= 0.0);\n    assert(height > 0.0);\n    return true;\n  }\n\n  @override\n  SliverGridLayout getLayout(SliverConstraints constraints) {\n    assert(_debugAssertIsValid());\n\n    int crossAxisCount =\n        (constraints.crossAxisExtent + crossAxisSpacing) ~/ (minWidth + crossAxisSpacing);\n\n    if (crossAxisCount < 1) crossAxisCount = 1;\n\n    double usableCrossAxisExtent =\n        constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1);\n    if (usableCrossAxisExtent < 0.0) usableCrossAxisExtent = 0.0;\n\n    final crossAxisExtent = usableCrossAxisExtent / crossAxisCount;\n\n    return SliverGridRegularTileLayout(\n      crossAxisCount: crossAxisCount,\n      mainAxisStride: height + mainAxisSpacing,\n      crossAxisStride: crossAxisExtent + crossAxisSpacing,\n      childMainAxisExtent: height,\n      childCrossAxisExtent: crossAxisExtent,\n      reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection),\n    );\n  }\n\n  @override\n  bool shouldRelayout(SliverGridDelegateWithMinWidthAndFixedHeight oldDelegate) =>\n      oldDelegate.height != height ||\n      oldDelegate.minWidth != minWidth ||\n      oldDelegate.mainAxisSpacing != mainAxisSpacing ||\n      oldDelegate.crossAxisSpacing != crossAxisSpacing;\n}\n\n/// Places as many items on the cross axis as possible, without making them\n/// narrower than [minWidth]. The item height is equal to the item width,\n/// multiplied by [rawHWRatio] and combined with [extraHeight].\nclass SliverGridDelegateWithMinWidthAndExtraHeight extends SliverGridDelegate {\n  const SliverGridDelegateWithMinWidthAndExtraHeight({\n    required this.minWidth,\n    this.mainAxisSpacing = Theming.offset,\n    this.crossAxisSpacing = Theming.offset,\n    this.extraHeight = 0.0,\n    this.rawHWRatio = 1.0,\n  }) : assert(minWidth >= 0),\n       assert(mainAxisSpacing >= 0),\n       assert(crossAxisSpacing >= 0),\n       assert(extraHeight >= 0),\n       assert(rawHWRatio > 0);\n\n  final double minWidth;\n  final double mainAxisSpacing;\n  final double crossAxisSpacing;\n  final double extraHeight;\n  final double rawHWRatio;\n\n  bool _debugAssertIsValid() {\n    assert(minWidth > 0.0);\n    assert(mainAxisSpacing >= 0.0);\n    assert(crossAxisSpacing >= 0.0);\n    assert(extraHeight >= 0.0);\n    assert(rawHWRatio > 0.0);\n    return true;\n  }\n\n  @override\n  SliverGridLayout getLayout(SliverConstraints constraints) {\n    assert(_debugAssertIsValid());\n\n    int crossAxisCount =\n        (constraints.crossAxisExtent + crossAxisSpacing) ~/ (minWidth + crossAxisSpacing);\n    if (crossAxisCount < 1) crossAxisCount = 1;\n\n    double usableCrossAxisExtent =\n        constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1);\n    if (usableCrossAxisExtent < 0.0) usableCrossAxisExtent = 0.0;\n\n    final crossAxisExtent = usableCrossAxisExtent / crossAxisCount;\n\n    final mainAxisExtent = crossAxisExtent * rawHWRatio + extraHeight;\n\n    return SliverGridRegularTileLayout(\n      crossAxisCount: crossAxisCount,\n      mainAxisStride: mainAxisExtent + mainAxisSpacing,\n      crossAxisStride: crossAxisExtent + crossAxisSpacing,\n      childMainAxisExtent: mainAxisExtent,\n      childCrossAxisExtent: crossAxisExtent,\n      reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection),\n    );\n  }\n\n  @override\n  bool shouldRelayout(SliverGridDelegateWithMinWidthAndExtraHeight oldDelegate) =>\n      oldDelegate.minWidth != minWidth ||\n      oldDelegate.mainAxisSpacing != mainAxisSpacing ||\n      oldDelegate.crossAxisSpacing != crossAxisSpacing ||\n      oldDelegate.extraHeight != extraHeight ||\n      oldDelegate.rawHWRatio != rawHWRatio;\n}\n"
  },
  {
    "path": "lib/widget/html_content.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/util/routes.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/loaders.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass HtmlContent extends StatelessWidget {\n  const HtmlContent(this.text, {this.renderMode = RenderMode.column});\n\n  final String text;\n  final RenderMode renderMode;\n\n  @override\n  Widget build(BuildContext context) {\n    return HtmlWidget(\n      text,\n      renderMode: renderMode,\n      textStyle: TextTheme.of(context).bodyMedium,\n      onTapUrl: (url) {\n        for (final matcher in _routeMatchers.entries) {\n          final match = matcher.key.firstMatch(url)?.group(1);\n          if (match != null) {\n            context.push(matcher.value(match));\n            return true;\n          }\n        }\n\n        return SnackBarExtension.launch(context, url);\n      },\n      onTapImage: (metadata) {\n        final source = metadata.sources.firstOrNull?.url;\n        if (source != null) {\n          showDialog(context: context, builder: (context) => ImageDialog(source));\n        }\n      },\n      onLoadingBuilder: (_, _, _) => const Center(child: Loader()),\n      onErrorBuilder: (_, element, err) => Center(\n        child: IconButton(\n          tooltip: 'Error',\n          icon: const Icon(Icons.close_outlined),\n          onPressed: () =>\n              SnackBarExtension.show(context, 'Failed to load element ${element.localName}'),\n        ),\n      ),\n      customStylesBuilder: (element) {\n        return switch (element.localName) {\n          'br' => const {'line-height': '15px'},\n          'i' || 'em' => const {'font-style': 'italic'},\n          'b' || 'strong' => const {'font-weight': '500'},\n          'h1' => const {'font-size': '20px', 'font-weight': '400'},\n          'h2' => const {'font-size': '18px', 'font-weight': '400'},\n          'h3' => const {'font-size': '17px', 'font-weight': '400'},\n          'h5' => const {'font-size': '13px', 'font-weight': '400'},\n          'h4' || 'h6' => const {'font-weight': '400'},\n          'a' => const {'text-decoration': 'none'},\n          'img' =>\n            element.attributes['width'] != null ? {'width': element.attributes['width']!} : null,\n          _ => const {},\n        };\n      },\n      customWidgetBuilder: (element) {\n        if (element.localName == 'hr') {\n          return Container(\n            height: 5,\n            width: double.infinity,\n            margin: const .symmetric(vertical: 5),\n            decoration: BoxDecoration(\n              color: ColorScheme.of(context).surfaceContainerHighest,\n              borderRadius: Theming.borderRadiusSmall,\n            ),\n          );\n        }\n\n        if (element.localName == 'youtube') {\n          return GestureDetector(\n            onTap: () =>\n                SnackBarExtension.launch(context, 'https://youtube.com/watch?v=${element.text}'),\n            child: Stack(\n              alignment: Alignment.center,\n              children: [\n                ConstrainedBox(\n                  constraints: const BoxConstraints(maxWidth: 240, maxHeight: 135),\n                  child: CachedImage('https://img.youtube.com/vi/${element.text}/0.jpg'),\n                ),\n                const Icon(Ionicons.logo_youtube, color: Color(0xFFFF0000), size: 40),\n              ],\n            ),\n          );\n        }\n\n        if (element.localName == 'video') {\n          final source = element.children.firstWhere((e) => e.localName == 'source');\n          final url = source.attributes['src'] ?? '';\n          return SizedBox(\n            width: double.infinity,\n            child: Center(\n              child: IconButton(\n                tooltip: 'WebM Video',\n                icon: const Icon(Ionicons.videocam, size: 50),\n                onPressed: () => showSheet(context, SimpleSheet.link(context, url)),\n              ),\n            ),\n          );\n        }\n\n        return null;\n      },\n    );\n  }\n}\n\nfinal _routeMatchers = {\n  RegExp(r'anilist.co\\/(?:anime|manga)\\/(\\d+)'): (String id) => Routes.media(int.parse(id)),\n  RegExp(r'anilist.co\\/user\\/([A-Za-z0-9]+)'): (String name) => Routes.userByName(name),\n  RegExp(r'anilist.co\\/character\\/(\\d+)'): (String id) => Routes.character(int.parse(id)),\n  RegExp(r'anilist.co\\/staff\\/(\\d+)'): (String id) => Routes.staff(int.parse(id)),\n  RegExp(r'anilist.co\\/studio\\/(\\d+)'): (String id) => Routes.studio(int.parse(id)),\n  RegExp(r'anilist.co\\/review\\/(\\d+)'): (String id) => Routes.review(int.parse(id)),\n  RegExp(r'anilist.co\\/activity\\/(\\d+)'): (String id) => Routes.activity(int.parse(id)),\n};\n"
  },
  {
    "path": "lib/widget/input/chip_selector.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/filter_chip_extension.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/shadowed_overflow_list.dart';\nimport 'package:otraku/feature/media/media_models.dart';\n\n/// A horizontal list of chips, where only one can be selected at a time.\nclass ChipSelector<T> extends StatefulWidget {\n  const ChipSelector._({\n    required this.title,\n    required this.items,\n    required this.value,\n    required this.onChanged,\n    required this.mustHaveSelected,\n    required this.highContrast,\n  });\n\n  /// Allows for nothing to be selected.\n  factory ChipSelector({\n    required String title,\n    required List<(String label, T value)> items,\n    required T? value,\n    required void Function(T?) onChanged,\n    required bool highContrast,\n  }) => ChipSelector._(\n    title: title,\n    items: items,\n    value: value,\n    onChanged: onChanged,\n    highContrast: highContrast,\n    mustHaveSelected: false,\n  );\n\n  /// Requires an option to be selected. [onChanged] will never receive `null`.\n  factory ChipSelector.ensureSelected({\n    required String title,\n    required List<(String label, T value)> items,\n    required T value,\n    required void Function(T) onChanged,\n    required bool highContrast,\n  }) => ChipSelector._(\n    title: title,\n    items: items,\n    value: value,\n    onChanged: (v) => onChanged(v ?? value),\n    highContrast: highContrast,\n    mustHaveSelected: true,\n  );\n\n  final String title;\n  final List<(String label, T value)> items;\n  final T? value;\n  final void Function(T?) onChanged;\n  final bool mustHaveSelected;\n  final bool highContrast;\n\n  @override\n  State<ChipSelector<T>> createState() => _ChipSelectorState<T>();\n}\n\nclass _ChipSelectorState<T> extends State<ChipSelector<T>> {\n  late T? _value = widget.value;\n\n  @override\n  void didUpdateWidget(covariant ChipSelector<T> oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _value = widget.value;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return _ChipSelector(\n      title: widget.title,\n      length: widget.items.length,\n      itemBuilder: (context, i) {\n        final (label, value) = widget.items[i];\n\n        return FilterChipExtension.highContrast(widget.highContrast)(\n          label: Text(label),\n          selected: value == _value,\n          onSelected: (selected) {\n            // Should not pass `null` if [widget.mustHaveSelected].\n            if (value == _value && widget.mustHaveSelected) return;\n\n            setState(() => selected ? _value = value : _value = null);\n            widget.onChanged(_value);\n          },\n        );\n      },\n    );\n  }\n}\n\n/// A horizontal list of chips, where zero or more are selected.\n/// Note: [values] are mutated directly.\nclass ChipMultiSelector<T> extends StatefulWidget {\n  const ChipMultiSelector({\n    required this.title,\n    required this.items,\n    required this.values,\n    required this.highContrast,\n  });\n\n  final String title;\n  final List<(String label, T value)> items;\n  final List<T> values;\n  final bool highContrast;\n\n  @override\n  State<ChipMultiSelector<T>> createState() => _ChipMultiSelectorState<T>();\n}\n\nclass _ChipMultiSelectorState<T> extends State<ChipMultiSelector<T>> {\n  @override\n  Widget build(BuildContext context) {\n    return _ChipSelector(\n      title: widget.title,\n      length: widget.items.length,\n      itemBuilder: (context, i) {\n        final (label, value) = widget.items[i];\n\n        return FilterChipExtension.highContrast(widget.highContrast)(\n          label: Text(label),\n          selected: widget.values.contains(value),\n          onSelected: (isSelected) {\n            setState(() => isSelected ? widget.values.add(value) : widget.values.remove(value));\n          },\n        );\n      },\n    );\n  }\n}\n\nclass EntrySortChipSelector extends StatefulWidget {\n  const EntrySortChipSelector({\n    required this.title,\n    required this.value,\n    required this.onChanged,\n    required this.highContrast,\n  });\n\n  final String title;\n  final EntrySort value;\n  final void Function(EntrySort) onChanged;\n  final bool highContrast;\n\n  @override\n  State<EntrySortChipSelector> createState() => _EntrySortChipSelectorState();\n}\n\nclass _EntrySortChipSelectorState extends State<EntrySortChipSelector> {\n  late var _value = widget.value;\n  final _labels = <String>[];\n\n  @override\n  void initState() {\n    super.initState();\n    for (int i = 0; i < EntrySort.values.length; i += 2) {\n      _labels.add(EntrySort.values[i].label);\n    }\n  }\n\n  @override\n  void didUpdateWidget(covariant EntrySortChipSelector oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _value = widget.value;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final unorderedValue = _value.index ~/ 2;\n    final isDescending = _value.index % 2 != 0;\n    final colorScheme = ColorScheme.of(context);\n\n    return _ChipSelector(\n      title: widget.title,\n      length: _labels.length,\n      itemBuilder: (context, index) => FilterChipExtension.highContrast(widget.highContrast)(\n        label: Text(_labels[index]),\n        showCheckmark: false,\n        avatar: unorderedValue == index\n            ? Icon(\n                isDescending ? Icons.arrow_downward_rounded : Icons.arrow_upward_rounded,\n                color: colorScheme.onPrimaryContainer,\n              )\n            : null,\n        selected: unorderedValue == index,\n        onSelected: (_) {\n          setState(() {\n            int i = index * 2;\n            if (unorderedValue == index) {\n              if (!isDescending) i++;\n            } else {\n              if (isDescending) i++;\n            }\n            _value = EntrySort.values.elementAt(i);\n          });\n          widget.onChanged(_value);\n        },\n      ),\n    );\n  }\n}\n\nclass _ChipSelector extends StatelessWidget {\n  const _ChipSelector({required this.title, required this.length, required this.itemBuilder});\n\n  final String title;\n  final int length;\n  final Widget Function(BuildContext, int) itemBuilder;\n\n  @override\n  Widget build(BuildContext context) {\n    return Column(\n      mainAxisSize: .min,\n      crossAxisAlignment: .start,\n      children: [\n        Padding(\n          padding: const .only(\n            top: Theming.offset / 2,\n            bottom: Theming.offset / 2,\n            right: Theming.offset,\n          ),\n          child: Text(title),\n        ),\n        SizedBox(\n          height: 40,\n          child: ShadowedOverflowList(itemCount: length, itemBuilder: itemBuilder),\n        ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/input/date_field.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/util/theming.dart';\n\nclass DateField extends StatefulWidget {\n  const DateField({required this.label, required this.value, required this.onChanged});\n\n  final String label;\n  final DateTime? value;\n  final Function(DateTime?) onChanged;\n\n  @override\n  State<DateField> createState() => _DateFieldState();\n}\n\nclass _DateFieldState extends State<DateField> {\n  late DateTime? _value = widget.value;\n  late final _ctrl = TextEditingController(text: _value?.formattedDate ?? '');\n\n  @override\n  void didUpdateWidget(covariant DateField oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _value = widget.value;\n    final text = _value?.formattedDate ?? '';\n    if (_ctrl.text != text) _ctrl.text = text;\n  }\n\n  @override\n  void dispose() {\n    _ctrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return TextField(\n      readOnly: true,\n      controller: _ctrl,\n      textAlign: .center,\n      style: TextTheme.of(context).bodyMedium,\n      onTap: () =>\n          showDatePicker(\n            context: context,\n            initialDate: _value ?? DateTime.now(),\n            firstDate: DateTime(1920),\n            lastDate: DateTime.now(),\n            errorInvalidText: 'Enter date in valid range',\n            errorFormatText: 'Enter valid date',\n            confirmText: 'Done',\n            cancelText: 'Cancel',\n            fieldLabelText: '',\n            helpText: '',\n          ).then((pickedDate) {\n            if (pickedDate == null) return;\n\n            _value = pickedDate;\n            _ctrl.text = _value?.formattedDate ?? '';\n            widget.onChanged(pickedDate);\n          }),\n      decoration: InputDecoration(\n        labelText: widget.label,\n        labelStyle: TextTheme.of(context).bodyMedium,\n        border: const OutlineInputBorder(),\n        suffixIcon: Semantics(\n          button: true,\n          child: Material(\n            color: Colors.transparent,\n            child: InkResponse(\n              radius: Theming.radiusSmall.x,\n              child: const Tooltip(message: 'Clear', child: Icon(Ionicons.close_outline)),\n              onTap: () {\n                _ctrl.text = '';\n                widget.onChanged(null);\n              },\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/input/note_label.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/dialogs.dart';\n\nclass NotesLabel extends StatelessWidget {\n  const NotesLabel(this.notes);\n\n  final String notes;\n\n  @override\n  Widget build(BuildContext context) {\n    if (notes.isEmpty) return const SizedBox();\n\n    return SizedBox(\n      height: 35,\n      child: Tooltip(\n        message: 'Comment',\n        child: InkResponse(\n          radius: Theming.radiusSmall.x,\n          child: const Icon(Ionicons.chatbox, size: Theming.iconSmall),\n          onTap: () => showDialog(\n            context: context,\n            builder: (context) => TextDialog(title: 'Comment', text: notes),\n          ),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/input/number_field.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:otraku/util/theming.dart';\n\nclass NumberField extends StatefulWidget {\n  const NumberField._({\n    required this.label,\n    required this.value,\n    required this.minValue,\n    required this.stepValue,\n    required this.maxValue,\n    required this.onChanged,\n    required this.isDecimal,\n  });\n\n  factory NumberField({\n    required String label,\n    required void Function(int) onChanged,\n    int value = 0,\n    int minValue = 0,\n    int? maxValue,\n  }) => NumberField._(\n    label: label,\n    value: value,\n    minValue: minValue,\n    stepValue: 1,\n    maxValue: maxValue,\n    onChanged: (n) => onChanged(n.toInt()),\n    isDecimal: false,\n  );\n\n  factory NumberField.decimal({\n    required String label,\n    required void Function(double) onChanged,\n    double value = 0.0,\n    double minValue = 0.0,\n    double? maxValue,\n  }) => NumberField._(\n    label: label,\n    value: value,\n    minValue: minValue,\n    stepValue: 0.5,\n    maxValue: maxValue,\n    onChanged: (n) => onChanged((n * 10).round() / 10),\n    isDecimal: true,\n  );\n\n  final String label;\n  final num value;\n  final num minValue;\n  final num stepValue;\n  final num? maxValue;\n  final void Function(num) onChanged;\n  final bool isDecimal;\n\n  @override\n  State<NumberField> createState() => _NumberFieldState();\n}\n\nclass _NumberFieldState extends State<NumberField> {\n  late final _ctrl = TextEditingController(text: widget.value.toString());\n  String? _error;\n\n  @override\n  void didUpdateWidget(covariant NumberField oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    final text = widget.value.toString();\n    if (text != _ctrl.text) {\n      _ctrl.value = TextEditingValue(\n        text: text,\n        selection: TextSelection(baseOffset: text.length, extentOffset: text.length),\n      );\n    }\n  }\n\n  @override\n  void dispose() {\n    _ctrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return TextField(\n      controller: _ctrl,\n      onChanged: _validateInput,\n      textAlign: .center,\n      style: TextTheme.of(context).bodyMedium,\n      keyboardType: TextInputType.numberWithOptions(decimal: widget.isDecimal),\n      inputFormatters: [\n        FilteringTextInputFormatter.allow(\n          widget.isDecimal ? RegExp(r'^\\d*\\.?\\d?$') : RegExp(r'\\d*'),\n        ),\n      ],\n      decoration: InputDecoration(\n        labelText: widget.label,\n        labelStyle: TextTheme.of(context).bodyMedium,\n        errorText: _error,\n        border: const OutlineInputBorder(),\n        prefixIcon: Semantics(\n          button: true,\n          child: Material(\n            color: Colors.transparent,\n            child: InkResponse(\n              onTap: () => _validateInput(_ctrl.text, -widget.stepValue),\n              radius: Theming.radiusSmall.x,\n              child: Tooltip(\n                message: 'Decrement',\n                onTriggered: () => _validateInput(widget.minValue.toString(), 0),\n                child: const Icon(Icons.remove),\n              ),\n            ),\n          ),\n        ),\n        suffixIcon: Semantics(\n          button: true,\n          child: Material(\n            color: Colors.transparent,\n            child: InkResponse(\n              onTap: () => _validateInput(_ctrl.text, widget.stepValue),\n              radius: Theming.radiusSmall.x,\n              child: Tooltip(\n                message: 'Increment',\n                onTriggered: () {\n                  if (widget.maxValue == null) return;\n                  _validateInput(widget.maxValue.toString(), 0);\n                },\n                child: const Icon(Icons.add),\n              ),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n\n  void _validateInput(String value, [num? add]) {\n    if (value.isEmpty) return;\n\n    num number = num.parse(value);\n    if (add != null) number += add;\n\n    // The value is allowed to go out of bounds while editing,\n    // but it should not affect the real state.\n    if (number < widget.minValue || widget.maxValue != null && number > widget.maxValue!) {\n      // Buttons can't make the field invalid, but manual edits can.\n      if (_error == null && add == null) {\n        setState(\n          () => number < widget.minValue\n              ? _error = 'Minimum ${widget.minValue}'\n              : _error = 'Maximum ${widget.maxValue}',\n        );\n      }\n      return;\n    }\n\n    if (_error != null) setState(() => _error = null);\n    widget.onChanged(number);\n\n    // If the field was changed manually, it shouldn't erase an unfinished edit.\n    if (add == null) return;\n\n    final text = number.toString();\n    _ctrl.value = _ctrl.value.copyWith(\n      text: text,\n      selection: TextSelection(baseOffset: text.length, extentOffset: text.length),\n      composing: TextRange.empty,\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/input/pill_selector.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/util/theming.dart';\n\nclass PillSelector extends StatelessWidget {\n  const PillSelector({\n    required this.selected,\n    required this.items,\n    required this.onTap,\n    this.maxWidth = double.infinity,\n    this.shrinkWrap = false,\n    this.scrollCtrl,\n  });\n\n  final int? selected;\n  final List<Widget> items;\n  final void Function(int) onTap;\n  final double maxWidth;\n  final bool shrinkWrap;\n  final ScrollController? scrollCtrl;\n\n  /// Approximation for a needed base height to display its contents.\n  /// Can be used to calculate the initial size of sheets.\n  static double expectedMinHeight(int itemCount) =>\n      (Theming.minTapTarget + Theming.offset / 2) * itemCount + Theming.offset * 2;\n\n  @override\n  Widget build(BuildContext context) {\n    return ConstrainedBox(\n      constraints: BoxConstraints(maxWidth: maxWidth),\n      child: ListView.separated(\n        controller: scrollCtrl,\n        shrinkWrap: shrinkWrap,\n        padding: MediaQuery.paddingOf(context).add(Theming.paddingAll),\n        itemCount: items.length,\n        separatorBuilder: (context, _) => const SizedBox(height: Theming.offset / 2),\n        itemBuilder: (context, i) => Material(\n          shape: const StadiumBorder(),\n          color: i == selected ? ColorScheme.of(context).secondaryContainer : null,\n          child: InkWell(\n            customBorder: const StadiumBorder(),\n            onTap: () => onTap(i),\n            child: ConstrainedBox(\n              constraints: const BoxConstraints(minHeight: Theming.minTapTarget),\n              child: Padding(\n                padding: const .symmetric(\n                  horizontal: Theming.offset * 1.5,\n                  vertical: Theming.offset * 0.5,\n                ),\n                child: Align(alignment: Alignment.centerLeft, child: items[i]),\n              ),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/input/score_label.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/feature/media/media_models.dart';\nimport 'package:otraku/util/theming.dart';\n\nclass ScoreLabel extends StatelessWidget {\n  const ScoreLabel(this.score, this.scoreFormat);\n\n  final double score;\n  final ScoreFormat scoreFormat;\n\n  @override\n  Widget build(BuildContext context) {\n    if (score == 0) return const SizedBox();\n\n    Widget content;\n    switch (scoreFormat) {\n      case .point3:\n        if (score == 3) {\n          content = const Icon(Icons.sentiment_very_satisfied, size: Theming.iconSmall);\n        } else if (score == 2) {\n          content = const Icon(Icons.sentiment_neutral, size: Theming.iconSmall);\n        } else {\n          content = const Icon(Icons.sentiment_very_dissatisfied, size: Theming.iconSmall);\n        }\n      case .point5:\n        content = Row(\n          mainAxisSize: .min,\n          spacing: 3,\n          children: [\n            Text(score.toStringAsFixed(0), style: TextTheme.of(context).labelSmall),\n            const Icon(Icons.star_rounded, size: Theming.iconSmall),\n          ],\n        );\n      case .point10Decimal:\n        content = Row(\n          mainAxisSize: .min,\n          spacing: 3,\n          children: [\n            const Icon(Icons.star_half_rounded, size: Theming.iconSmall),\n            Text(score.toStringAsFixed(1), style: TextTheme.of(context).labelSmall),\n          ],\n        );\n      default:\n        content = Row(\n          mainAxisSize: .min,\n          spacing: 3,\n          children: [\n            const Icon(Icons.star_half_rounded, size: Theming.iconSmall),\n            Text(score.toStringAsFixed(0), style: TextTheme.of(context).labelSmall),\n          ],\n        );\n    }\n\n    return Tooltip(message: 'Score', child: content);\n  }\n}\n"
  },
  {
    "path": "lib/widget/input/search_field.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/util/debounce.dart';\n\nclass SearchField extends StatefulWidget {\n  const SearchField({\n    required this.value,\n    required this.hint,\n    required this.onChanged,\n    this.focusNode,\n    this.debounce,\n  });\n\n  final String value;\n  final String hint;\n  final void Function(String) onChanged;\n  final FocusNode? focusNode;\n  final Debounce? debounce;\n\n  @override\n  State<SearchField> createState() => _SearchFieldState();\n}\n\nclass _SearchFieldState extends State<SearchField> {\n  late final _ctrl = TextEditingController(text: widget.value);\n\n  @override\n  void didUpdateWidget(covariant SearchField oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    if (_ctrl.text != widget.value) _ctrl.text = widget.value;\n  }\n\n  @override\n  void dispose() {\n    _ctrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return Semantics(\n      label: 'Search',\n      child: TextField(\n        controller: _ctrl,\n        focusNode: widget.focusNode,\n        style: TextTheme.of(context).bodyMedium,\n        onChanged: (val) {\n          if (val.isEmpty) {\n            widget.debounce?.cancel();\n            widget.onChanged('');\n            return;\n          }\n\n          if (widget.debounce != null) {\n            widget.debounce!.run(() => widget.onChanged(val));\n          } else {\n            widget.onChanged(val);\n          }\n        },\n        decoration: InputDecoration(\n          isDense: false,\n          hintText: widget.hint,\n          filled: true,\n          fillColor: ColorScheme.of(context).surfaceContainerHighest,\n          contentPadding: const .only(left: 15),\n          constraints: const BoxConstraints(minHeight: 35, maxHeight: 40),\n          suffixIcon: _ctrl.text.isNotEmpty\n              ? IconButton(\n                  tooltip: 'Clear',\n                  iconSize: Theming.iconSmall,\n                  icon: const Icon(Icons.close_rounded),\n                  color: ColorScheme.of(context).onSurface,\n                  padding: const .all(0),\n                  onPressed: () {\n                    _ctrl.clear();\n                    widget.debounce?.cancel();\n                    widget.onChanged('');\n                  },\n                )\n              : null,\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/input/stateful_tiles.dart",
    "content": "import 'package:flutter/material.dart';\n\n/// A wrapper around [SwitchListTile.adaptive], which handles state.\nclass StatefulSwitchListTile extends StatefulWidget {\n  const StatefulSwitchListTile({\n    required this.title,\n    required this.value,\n    required this.onChanged,\n    this.subtitle,\n  });\n\n  final Widget title;\n  final Widget? subtitle;\n  final bool value;\n  final void Function(bool) onChanged;\n\n  @override\n  State<StatefulSwitchListTile> createState() => _StatefulSwitchListTileState();\n}\n\nclass _StatefulSwitchListTileState extends State<StatefulSwitchListTile> {\n  late bool _value = widget.value;\n\n  @override\n  void didUpdateWidget(covariant StatefulSwitchListTile oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _value = widget.value;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return SwitchListTile.adaptive(\n      // The active color needs to be overriden, because\n      // the cupertino selected state won't pick it up otherwise.\n      activeTrackColor: ColorScheme.of(context).primary,\n      title: widget.title,\n      subtitle: widget.subtitle,\n      value: _value,\n      onChanged: (v) {\n        setState(() => _value = v);\n        widget.onChanged(v);\n      },\n    );\n  }\n}\n\n/// A wrapper around [CheckboxListTile.adaptive], which handles state.\nclass StatefulCheckboxListTile extends StatefulWidget {\n  const StatefulCheckboxListTile({\n    required this.value,\n    required this.onChanged,\n    this.tristate = false,\n    this.title,\n  });\n\n  final bool? value;\n  final void Function(bool?) onChanged;\n  final Widget? title;\n  final bool tristate;\n\n  @override\n  State<StatefulCheckboxListTile> createState() => _StatefulCheckboxListTileState();\n}\n\nclass _StatefulCheckboxListTileState extends State<StatefulCheckboxListTile> {\n  late bool? _value = widget.value;\n\n  @override\n  void didUpdateWidget(covariant StatefulCheckboxListTile oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _value = widget.value;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return CheckboxListTile.adaptive(\n      // The active color needs to be overriden, because\n      // the cupertino selected state won't pick it up otherwise.\n      activeColor: ColorScheme.of(context).primary,\n      title: widget.title,\n      tristate: widget.tristate,\n      value: _value,\n      onChanged: (v) {\n        setState(() => _value = v);\n        widget.onChanged(v);\n      },\n    );\n  }\n}\n\nclass StatefulSegmentedButton<T> extends StatefulWidget {\n  const StatefulSegmentedButton({\n    super.key,\n    required this.value,\n    required this.onChanged,\n    required this.segments,\n  });\n\n  final T value;\n  final void Function(T) onChanged;\n  final List<ButtonSegment<T>> segments;\n\n  @override\n  State<StatefulSegmentedButton<T>> createState() => _StatefulSegmentedButtonState<T>();\n}\n\nclass _StatefulSegmentedButtonState<T> extends State<StatefulSegmentedButton<T>> {\n  late var _value = widget.value;\n\n  @override\n  void didUpdateWidget(covariant StatefulSegmentedButton<T> oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _value = widget.value;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return SegmentedButton(\n      selected: {_value},\n      segments: widget.segments,\n      onSelectionChanged: (value) {\n        setState(() => _value = value.first);\n        widget.onChanged(_value);\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/input/year_range_picker.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter/widgets.dart';\nimport 'package:otraku/widget/input/number_field.dart';\n\nconst _minYear = 1917;\n\nclass YearRangePicker extends StatefulWidget {\n  const YearRangePicker({\n    required this.title,\n    required this.from,\n    required this.to,\n    required this.onChanged,\n  });\n\n  final String title;\n  final int? from;\n  final int? to;\n  final void Function(int?, int?) onChanged;\n\n  @override\n  State<YearRangePicker> createState() => _YearRangePickerState();\n}\n\nclass _YearRangePickerState extends State<YearRangePicker> {\n  late int _maxYear;\n  late int _from;\n  late int _to;\n\n  @override\n  void initState() {\n    super.initState();\n    _init();\n  }\n\n  @override\n  void didUpdateWidget(covariant YearRangePicker oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _init();\n  }\n\n  void _init() {\n    _maxYear = DateTime.now().year + 1;\n    _from = widget.from ?? _minYear;\n    _to = widget.to ?? _maxYear;\n    if (_from < _minYear) _from = _minYear;\n    if (_to > _maxYear) _to = _maxYear;\n    if (_from > _to) _from = _to;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return Row(\n      children: [\n        Expanded(\n          child: NumberField(\n            label: 'Release Start',\n            value: _from,\n            minValue: _minYear,\n            maxValue: _maxYear,\n            onChanged: (from) {\n              setState(() {\n                _from = from;\n                if (_to < _from) _to = _from;\n              });\n\n              _from > _minYear || _to < _maxYear\n                  ? widget.onChanged(_from, _to)\n                  : widget.onChanged(null, null);\n            },\n          ),\n        ),\n        const SizedBox(width: 10),\n        Expanded(\n          child: NumberField(\n            label: 'Release End',\n            value: _to,\n            minValue: _minYear,\n            maxValue: _maxYear,\n            onChanged: (to) {\n              setState(() {\n                _to = to;\n                if (_from > _to) _from = _to;\n              });\n\n              _from > _minYear || _to < _maxYear\n                  ? widget.onChanged(_from, _to)\n                  : widget.onChanged(null, null);\n            },\n          ),\n        ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/layout/adaptive_scaffold.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/layout/hiding_floating_action_button.dart';\nimport 'package:otraku/widget/layout/navigation_tool.dart';\n\nclass AdaptiveScaffold extends StatelessWidget {\n  const AdaptiveScaffold({\n    required this.child,\n    this.topBar,\n    this.floatingAction,\n    this.navigationConfig,\n    this.bottomBar,\n    this.sheetMode = false,\n  }) : assert(\n         navigationConfig == null || bottomBar == null,\n         'Cannot have both a navigation bar and a custom bottom bar',\n       );\n\n  final Widget child;\n  final PreferredSizeWidget? topBar;\n  final HidingFloatingActionButton? floatingAction;\n  final NavigationConfig? navigationConfig;\n  final Widget? bottomBar;\n  final bool sheetMode;\n\n  @override\n  Widget build(BuildContext context) {\n    final theming = Theming.of(context);\n\n    Color? backgroundColor;\n    bool? resizeToAvoidBottomInset;\n    if (sheetMode) {\n      backgroundColor = Colors.transparent;\n      resizeToAvoidBottomInset = false;\n    }\n\n    var startFabLocation = _StartFloatFabLocation.withoutOffset;\n    const endFabLocation = FloatingActionButtonLocation.endFloat;\n\n    var effectiveChild = child;\n    var effectiveBottomBar = bottomBar;\n    if (navigationConfig != null) {\n      switch (theming.formFactor) {\n        case .phone:\n          effectiveBottomBar = BottomNavigation(\n            selected: navigationConfig!.selected,\n            items: navigationConfig!.items,\n            onChanged: navigationConfig!.onChanged,\n            onSame: navigationConfig!.onSame,\n          );\n        case .tablet:\n          final sideNavigation = SideNavigation(\n            selected: navigationConfig!.selected,\n            items: navigationConfig!.items,\n            onChanged: navigationConfig!.onChanged,\n            onSame: navigationConfig!.onSame,\n          );\n\n          startFabLocation = _StartFloatFabLocation.withOffset;\n\n          effectiveChild = Expanded(child: effectiveChild);\n          effectiveChild = Row(\n            children: Directionality.of(context) == TextDirection.ltr\n                ? [sideNavigation, effectiveChild]\n                : [effectiveChild, sideNavigation],\n          );\n      }\n    }\n\n    return SafeArea(\n      top: false,\n      bottom: false,\n      child: Scaffold(\n        extendBody: true,\n        extendBodyBehindAppBar: true,\n        backgroundColor: backgroundColor,\n        resizeToAvoidBottomInset: resizeToAvoidBottomInset,\n        appBar: topBar,\n        bottomNavigationBar: effectiveBottomBar,\n        floatingActionButton: floatingAction,\n        floatingActionButtonLocation: theming.rightButtonOrientation\n            ? endFabLocation\n            : startFabLocation,\n        body: effectiveChild,\n      ),\n    );\n  }\n}\n\n/// A configuration that can be shared\n/// between bottom navigation bars and navigation rails.\nclass NavigationConfig {\n  const NavigationConfig({\n    required this.selected,\n    required this.items,\n    required this.onChanged,\n    required this.onSame,\n  });\n\n  final int selected;\n  final Map<String, IconData> items;\n  final void Function(int) onChanged;\n  final void Function(int) onSame;\n}\n\nclass _StartFloatFabLocation extends StandardFabLocation with FabStartOffsetX, FabFloatOffsetY {\n  const _StartFloatFabLocation(this.offset);\n\n  static const withOffset = _StartFloatFabLocation(Theming.normalTapTarget * 1.5);\n\n  static const withoutOffset = _StartFloatFabLocation(0);\n\n  final double offset;\n\n  @override\n  double getOffsetX(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) {\n    return switch (scaffoldGeometry.textDirection) {\n      TextDirection.rtl => super.getOffsetX(scaffoldGeometry, adjustment + offset),\n      TextDirection.ltr => super.getOffsetX(scaffoldGeometry, adjustment - offset),\n    };\n  }\n\n  @override\n  String toString() => 'FloatingActionButtonLocation.startFloatWithOffset';\n}\n"
  },
  {
    "path": "lib/widget/layout/constrained_view.dart",
    "content": "import 'package:flutter/widgets.dart';\nimport 'package:otraku/util/theming.dart';\n\n/// Horizontally constrains [child] in the center.\nclass ConstrainedView extends StatelessWidget {\n  const ConstrainedView({required this.child, this.padded = true});\n\n  final Widget child;\n  final bool padded;\n\n  @override\n  Widget build(BuildContext context) {\n    return Center(\n      child: Padding(\n        padding: padded ? const .symmetric(horizontal: Theming.offset) : .zero,\n        child: ConstrainedBox(\n          constraints: const BoxConstraints(maxWidth: Theming.windowWidthMedium),\n          child: child,\n        ),\n      ),\n    );\n  }\n}\n\n/// An alternative to [ConstrainedView] for Sliver views.\nclass SliverConstrainedView extends StatelessWidget {\n  const SliverConstrainedView({required this.sliver});\n\n  final Widget sliver;\n\n  @override\n  Widget build(BuildContext context) {\n    return SliverLayoutBuilder(\n      builder: (context, constraints) {\n        final side = (constraints.crossAxisExtent - Theming.windowWidthMedium) / 2;\n\n        return SliverPadding(\n          padding: .symmetric(horizontal: side < Theming.offset ? Theming.offset : side),\n          sliver: sliver,\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/layout/content_header.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/cached_image.dart';\nimport 'package:otraku/widget/dialogs.dart';\nimport 'package:otraku/widget/sheets.dart';\n\nclass CustomContentHeader extends StatelessWidget {\n  const CustomContentHeader({\n    required this.title,\n    required this.content,\n    required this.siteUrl,\n    this.bannerUrl,\n    this.trailingTopButtons = const [],\n    this.tabBarConfig,\n  });\n\n  final String? title;\n  final PreferredSizeWidget content;\n  final String? siteUrl;\n  final String? bannerUrl;\n  final List<Widget> trailingTopButtons;\n  final TabBarConfig? tabBarConfig;\n\n  @override\n  Widget build(BuildContext context) {\n    return SliverPersistentHeader(\n      pinned: true,\n      delegate: _Delegate(\n        content: content,\n        title: title,\n        siteUrl: siteUrl,\n        trailingTopButtons: trailingTopButtons,\n        bannerUrl: bannerUrl,\n        tabBarConfig: tabBarConfig,\n        topPadding: MediaQuery.paddingOf(context).top,\n      ),\n    );\n  }\n}\n\nclass ContentHeader extends StatelessWidget {\n  const ContentHeader({\n    required this.imageUrl,\n    required this.imageHeroTag,\n    required this.imageHeightToWidthRatio,\n    required this.title,\n    required this.siteUrl,\n    this.imageLargeUrl,\n    this.imageFit = BoxFit.cover,\n    this.trailingTopButtons = const [],\n    this.details = const [],\n    this.bannerUrl,\n    this.tabBarConfig,\n  });\n\n  final String? imageUrl;\n  final String? imageLargeUrl;\n  final Object imageHeroTag;\n  final double imageHeightToWidthRatio;\n  final BoxFit imageFit;\n  final String? title;\n  final List<Widget> details;\n  final List<Widget> trailingTopButtons;\n  final String? siteUrl;\n  final String? bannerUrl;\n  final TabBarConfig? tabBarConfig;\n\n  @override\n  Widget build(BuildContext context) {\n    final imageWidth = ((MediaQuery.sizeOf(context).width - Theming.offset * 3) / 2.0).clamp(\n      0.0,\n      100.0,\n    );\n    final imageHeight = imageWidth * imageHeightToWidthRatio;\n\n    final content = _ImageContent(\n      imageWidth: imageWidth,\n      imageHeight: imageHeight,\n      imageHeroTag: imageHeroTag,\n      imageUrl: imageUrl,\n      imageLargeUrl: imageLargeUrl,\n      imageFit: imageFit,\n      title: title,\n      details: details,\n    );\n\n    return SliverPersistentHeader(\n      pinned: true,\n      delegate: _Delegate(\n        content: content,\n        title: title,\n        siteUrl: siteUrl,\n        trailingTopButtons: trailingTopButtons,\n        bannerUrl: bannerUrl,\n        tabBarConfig: tabBarConfig,\n        topPadding: MediaQuery.paddingOf(context).top,\n      ),\n    );\n  }\n}\n\ntypedef TabBarConfig = ({TabController tabCtrl, List<Tab> tabs, void Function() scrollToTop});\n\nclass _ImageContent extends StatelessWidget implements PreferredSizeWidget {\n  const _ImageContent({\n    required this.imageWidth,\n    required this.imageHeight,\n    required this.imageHeroTag,\n    required this.imageUrl,\n    required this.imageLargeUrl,\n    required this.imageFit,\n    required this.title,\n    required this.details,\n  });\n\n  final double imageWidth;\n  final double imageHeight;\n  final Object imageHeroTag;\n  final String? imageUrl;\n  final String? imageLargeUrl;\n  final BoxFit imageFit;\n  final String? title;\n  final List<Widget> details;\n\n  @override\n  Size get preferredSize => Size.fromHeight(imageHeight);\n\n  @override\n  Widget build(BuildContext context) {\n    return Row(\n      crossAxisAlignment: .end,\n      spacing: Theming.offset,\n      children: [\n        Hero(\n          tag: imageHeroTag,\n          child: ClipRRect(\n            borderRadius: Theming.borderRadiusSmall,\n            child: SizedBox(\n              height: imageHeight,\n              width: imageWidth,\n              child: imageUrl != null\n                  ? GestureDetector(\n                      onTap: () => showDialog(\n                        context: context,\n                        builder: (context) => ImageDialog(imageLargeUrl ?? imageUrl!),\n                      ),\n                      child: CachedImage(imageUrl!, fit: imageFit),\n                    )\n                  : null,\n            ),\n          ),\n        ),\n        Expanded(\n          child: Center(\n            child: SingleChildScrollView(\n              child: Column(\n                crossAxisAlignment: .stretch,\n                spacing: 5,\n                children: [\n                  if (title != null)\n                    GestureDetector(\n                      behavior: .opaque,\n                      onTap: () => SnackBarExtension.copy(context, title!),\n                      child: Text(title!, overflow: .fade, style: TextTheme.of(context).bodyLarge),\n                    ),\n                  ...details,\n                ],\n              ),\n            ),\n          ),\n        ),\n      ],\n    );\n  }\n}\n\nclass _Delegate extends SliverPersistentHeaderDelegate {\n  const _Delegate({\n    required this.content,\n    required this.title,\n    required this.siteUrl,\n    required this.bannerUrl,\n    required this.tabBarConfig,\n    required this.trailingTopButtons,\n    required this.topPadding,\n  });\n\n  final PreferredSizeWidget content;\n  final double topPadding;\n  final String? title;\n  final List<Widget> trailingTopButtons;\n  final String? siteUrl;\n  final String? bannerUrl;\n  final TabBarConfig? tabBarConfig;\n\n  @override\n  double get minExtent =>\n      topPadding + Theming.normalTapTarget + (tabBarConfig != null ? Theming.minTapTarget : 0);\n\n  @override\n  double get maxExtent => minExtent + content.preferredSize.height + Theming.offset;\n\n  @override\n  bool shouldRebuild(covariant _Delegate oldDelegate) =>\n      topPadding != oldDelegate.topPadding || title != oldDelegate.title;\n\n  @override\n  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {\n    final theme = Theme.of(context);\n\n    final minHeight = minExtent;\n    final maxHeight = maxExtent;\n    final transition = (shrinkOffset / (maxHeight - minHeight)).clamp(0.0, 1.0);\n\n    final topButtons = Row(\n      children: [\n        if (GoRouter.of(context).canPop())\n          IconButton(\n            tooltip: 'Close',\n            icon: const Icon(Icons.arrow_back_ios_rounded),\n            onPressed: context.back,\n          )\n        else\n          const SizedBox(width: Theming.offset),\n        if (title == null)\n          const Spacer()\n        else\n          Expanded(\n            child: Opacity(\n              opacity: transition,\n              child: Text(title!, style: theme.textTheme.bodyMedium, overflow: .ellipsis),\n            ),\n          ),\n        ...trailingTopButtons,\n        IconButton(\n          tooltip: 'More',\n          icon: const Icon(Ionicons.ellipsis_horizontal),\n          onPressed: siteUrl != null\n              ? () => showSheet(context, SimpleSheet.link(context, siteUrl!))\n              : null,\n        ),\n      ],\n    );\n\n    final bannerBottomPadding = content.preferredSize.height / 2.0 + Theming.offset / 2;\n\n    Widget body = Stack(\n      fit: StackFit.expand,\n      children: [\n        if (transition < 1) ...[\n          if (bannerUrl != null) ...[\n            Positioned.fill(bottom: bannerBottomPadding, child: CachedImage(bannerUrl!)),\n            Positioned.fill(\n              bottom: bannerBottomPadding,\n              child: GestureDetector(\n                onTap: () =>\n                    showDialog(context: context, builder: (context) => ImageDialog(bannerUrl!)),\n                child: DecoratedBox(\n                  decoration: BoxDecoration(\n                    gradient: LinearGradient(\n                      begin: Alignment.topCenter,\n                      end: Alignment.center,\n                      tileMode: TileMode.mirror,\n                      colors: [theme.colorScheme.surface, theme.colorScheme.surface.withAlpha(150)],\n                    ),\n                  ),\n                ),\n              ),\n            ),\n          ],\n          Positioned(\n            left: Theming.offset,\n            right: Theming.offset,\n            bottom: Theming.offset / 2,\n            top: Theming.offset / 2 + topPadding + Theming.normalTapTarget,\n            child: content,\n          ),\n          if (transition > 0.1)\n            Positioned.fill(\n              child: DecoratedBox(\n                decoration: BoxDecoration(\n                  color: theme.colorScheme.surface.withAlpha((transition * 255).floor()),\n                ),\n              ),\n            ),\n        ],\n        Positioned(\n          left: 0,\n          right: 0,\n          top: topPadding,\n          height: Theming.normalTapTarget,\n          child: topButtons,\n        ),\n      ],\n    );\n\n    if (tabBarConfig != null) {\n      body = Column(\n        children: [\n          Flexible(child: body),\n          Material(\n            color: Colors.transparent,\n            child: TabBar(\n              tabAlignment: TabAlignment.center,\n              splashBorderRadius: Theming.borderRadiusSmall,\n              controller: tabBarConfig!.tabCtrl,\n              isScrollable: true,\n              tabs: tabBarConfig!.tabs,\n              onTap: (index) {\n                if (index == tabBarConfig!.tabCtrl.index) {\n                  tabBarConfig!.scrollToTop();\n                }\n              },\n            ),\n          ),\n        ],\n      );\n    }\n\n    return transition < 1\n        ? body\n        : ClipRect(\n            child: BackdropFilter(\n              filter: Theming.blurFilter,\n              child: DecoratedBox(\n                decoration: BoxDecoration(color: theme.navigationBarTheme.backgroundColor),\n                child: body,\n              ),\n            ),\n          );\n  }\n}\n"
  },
  {
    "path": "lib/widget/layout/dual_pane_with_tab_bar.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/util/theming.dart';\n\n/// Two panes side by side, the left with capped width.\n/// There's a tab bar over the right one.\nclass DualPaneWithTabBar extends StatelessWidget {\n  const DualPaneWithTabBar({\n    required this.tabs,\n    required this.tabCtrl,\n    required this.scrollToTop,\n    required this.leftPane,\n    required this.rightPane,\n  });\n\n  final List<Tab> tabs;\n  final TabController tabCtrl;\n  final void Function() scrollToTop;\n  final Widget leftPane;\n  final Widget rightPane;\n\n  @override\n  Widget build(BuildContext context) {\n    final mediaQuery = MediaQuery.of(context);\n    final topPadding = mediaQuery.padding.top + Theming.normalTapTarget;\n\n    return Row(\n      children: [\n        Flexible(\n          child: ConstrainedBox(\n            constraints: const BoxConstraints(maxWidth: Theming.windowWidthMedium),\n            child: leftPane,\n          ),\n        ),\n        Flexible(\n          child: Stack(\n            children: [\n              MediaQuery(\n                data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: topPadding)),\n                child: rightPane,\n              ),\n              Align(\n                alignment: Alignment.topCenter,\n                child: ClipRect(\n                  child: BackdropFilter(\n                    filter: Theming.blurFilter,\n                    child: DecoratedBox(\n                      decoration: BoxDecoration(\n                        color: Theme.of(context).navigationBarTheme.backgroundColor,\n                      ),\n                      child: SizedBox(\n                        height: topPadding,\n                        child: Align(\n                          alignment: Alignment.bottomCenter,\n                          child: Material(\n                            color: Colors.transparent,\n                            child: TabBar(\n                              isScrollable: true,\n                              tabAlignment: TabAlignment.center,\n                              splashBorderRadius: Theming.borderRadiusSmall,\n                              tabs: tabs,\n                              controller: tabCtrl,\n                              onTap: (index) {\n                                if (index == tabCtrl.index) {\n                                  scrollToTop();\n                                }\n                              },\n                            ),\n                          ),\n                        ),\n                      ),\n                    ),\n                  ),\n                ),\n              ),\n            ],\n          ),\n        ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/layout/hiding_floating_action_button.dart",
    "content": "import 'package:flutter/material.dart';\n\n/// Hides/Shows [child] on scroll.\nclass HidingFloatingActionButton extends StatefulWidget {\n  const HidingFloatingActionButton({\n    required super.key,\n    required this.child,\n    required this.scrollCtrl,\n  });\n\n  final Widget child;\n  final ScrollController scrollCtrl;\n\n  @override\n  State<HidingFloatingActionButton> createState() => _HidingFloatingActionButtonState();\n}\n\nclass _HidingFloatingActionButtonState extends State<HidingFloatingActionButton>\n    with SingleTickerProviderStateMixin {\n  late final AnimationController _animationCtrl;\n  late final Animation<Offset> _slideAnimation;\n  late final Animation<double> _fadeAnimation;\n\n  var _visible = true;\n  var _lastOffset = 0.0;\n\n  void _visibility() {\n    final pos = widget.scrollCtrl.positions.last;\n    final dif = pos.pixels - _lastOffset;\n\n    // If the position has moved enough from the last\n    // spot or is out of bounds, hide/show the actions.\n    if (dif > 15 || pos.pixels > pos.maxScrollExtent) {\n      _lastOffset = pos.pixels;\n      _animationCtrl.reverse().then((_) => setState(() => _visible = false));\n    } else if (dif < -15 || pos.pixels < pos.minScrollExtent) {\n      _lastOffset = pos.pixels;\n      setState(() => _visible = true);\n      _animationCtrl.forward();\n    }\n  }\n\n  @override\n  void initState() {\n    super.initState();\n    widget.scrollCtrl.addListener(_visibility);\n\n    _animationCtrl = AnimationController(\n      duration: const Duration(milliseconds: 100),\n      vsync: this,\n      value: 1,\n    );\n    _slideAnimation = Tween(begin: const Offset(0, 0.2), end: Offset.zero).animate(_animationCtrl);\n    _fadeAnimation = Tween(begin: 0.3, end: 1.0).animate(_animationCtrl);\n  }\n\n  @override\n  void dispose() {\n    widget.scrollCtrl.removeListener(_visibility);\n    _animationCtrl.dispose();\n    super.dispose();\n  }\n\n  @override\n  void didUpdateWidget(covariant HidingFloatingActionButton oldWidget) {\n    super.didUpdateWidget(oldWidget);\n\n    if (widget.scrollCtrl != oldWidget.scrollCtrl) {\n      oldWidget.scrollCtrl.removeListener(_visibility);\n      widget.scrollCtrl.addListener(_visibility);\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    if (!_visible) return const SizedBox();\n\n    return SlideTransition(\n      position: _slideAnimation,\n      child: FadeTransition(opacity: _fadeAnimation, child: widget.child),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/layout/navigation_tool.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/util/theming.dart';\n\nclass BottomNavigation extends StatefulWidget {\n  const BottomNavigation({\n    required this.selected,\n    required this.items,\n    required this.onChanged,\n    required this.onSame,\n  });\n\n  final int selected;\n  final Map<String, IconData> items;\n  final void Function(int) onChanged;\n  final void Function(int) onSame;\n\n  @override\n  State<BottomNavigation> createState() => _BottomNavigationState();\n}\n\nclass _BottomNavigationState extends State<BottomNavigation> {\n  late int _selected = widget.selected;\n\n  @override\n  void didUpdateWidget(covariant BottomNavigation oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _selected = widget.selected;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return ClipRect(\n      child: BackdropFilter(\n        filter: Theming.blurFilter,\n        child: NavigationBar(\n          height: BottomBar.height,\n          selectedIndex: _selected,\n          onDestinationSelected: (i) {\n            if (_selected == i) {\n              widget.onSame(i);\n            } else {\n              _selected = i;\n              widget.onChanged(_selected);\n            }\n          },\n          destinations: [\n            for (final e in widget.items.entries)\n              NavigationDestination(label: e.key, icon: Icon(e.value)),\n          ],\n        ),\n      ),\n    );\n  }\n}\n\nclass SideNavigation extends StatefulWidget {\n  const SideNavigation({\n    required this.selected,\n    required this.items,\n    required this.onChanged,\n    required this.onSame,\n  });\n\n  final int selected;\n  final Map<String, IconData> items;\n  final void Function(int) onChanged;\n  final void Function(int) onSame;\n\n  @override\n  State<SideNavigation> createState() => _SideNavigationState();\n}\n\nclass _SideNavigationState extends State<SideNavigation> {\n  late int _selected = widget.selected;\n\n  @override\n  void didUpdateWidget(covariant SideNavigation oldWidget) {\n    super.didUpdateWidget(oldWidget);\n    _selected = widget.selected;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final rail = NavigationRail(\n      scrollable: true,\n      selectedIndex: _selected,\n      onDestinationSelected: (i) {\n        if (_selected == i) {\n          widget.onSame(i);\n        } else {\n          _selected = i;\n          widget.onChanged(_selected);\n        }\n      },\n      destinations: [\n        for (final e in widget.items.entries)\n          NavigationRailDestination(label: Text(e.key), icon: Icon(e.value)),\n      ],\n    );\n\n    return ClipRect(\n      child: BackdropFilter(filter: Theming.blurFilter, child: rail),\n    );\n  }\n}\n\nclass BottomBar extends StatelessWidget {\n  const BottomBar(this.items);\n\n  final List<Widget> items;\n\n  static const height = 60.0;\n\n  @override\n  Widget build(BuildContext context) {\n    final bottomPadding = MediaQuery.paddingOf(context).bottom;\n\n    return ClipRect(\n      child: BackdropFilter(\n        filter: Theming.blurFilter,\n        child: SizedBox(\n          height: height + bottomPadding,\n          child: Material(\n            elevation: 3,\n            color: Theme.of(context).navigationBarTheme.backgroundColor,\n            surfaceTintColor: ColorScheme.of(context).surfaceTint,\n            shadowColor: Colors.transparent,\n            child: Padding(\n              padding: .only(bottom: bottomPadding),\n              child: Row(mainAxisAlignment: .spaceEvenly, children: items),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass BottomBarButton extends StatelessWidget {\n  const BottomBarButton({\n    required this.text,\n    required this.icon,\n    required this.onTap,\n    this.foregroundColor,\n  });\n\n  final String text;\n  final IconData icon;\n  final void Function() onTap;\n  final Color? foregroundColor;\n\n  @override\n  Widget build(BuildContext context) {\n    return Padding(\n      padding: const .symmetric(horizontal: Theming.offset),\n      child: TextButton.icon(\n        label: Text(text),\n        icon: Icon(icon),\n        onPressed: onTap,\n        style: TextButton.styleFrom(\n          foregroundColor: foregroundColor,\n          iconColor: foregroundColor,\n          iconSize: Theming.iconBig,\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/layout/top_bar.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:go_router/go_router.dart';\nimport 'package:otraku/extension/build_context_extension.dart';\nimport 'package:otraku/util/theming.dart';\n\nconst _preferredSize = Size.fromHeight(Theming.normalTapTarget);\n\n/// A top app bar implementation that uses a blurred, translucent background.\nclass TopBar extends StatelessWidget implements PreferredSizeWidget {\n  const TopBar({super.key, this.title, this.trailing = const []});\n\n  final String? title;\n  final List<Widget> trailing;\n\n  @override\n  Size get preferredSize => _preferredSize;\n\n  @override\n  Widget build(BuildContext context) {\n    final topPadding = MediaQuery.paddingOf(context).top;\n\n    return ClipRect(\n      child: BackdropFilter(\n        filter: Theming.blurFilter,\n        child: Container(\n          height: topPadding + preferredSize.height,\n          decoration: BoxDecoration(color: Theme.of(context).navigationBarTheme.backgroundColor),\n          padding: .only(top: topPadding),\n          alignment: Alignment.center,\n          child: Row(\n            children: [\n              if (GoRouter.of(context).canPop())\n                IconButton(\n                  tooltip: 'Close',\n                  icon: const Icon(Icons.arrow_back_ios_rounded),\n                  onPressed: context.back,\n                )\n              else\n                const SizedBox(width: Theming.offset),\n              if (title != null)\n                Expanded(\n                  child: Text(\n                    title!,\n                    style: TextTheme.of(context).titleMedium,\n                    overflow: .ellipsis,\n                    maxLines: 1,\n                  ),\n                ),\n              ...trailing,\n            ],\n          ),\n        ),\n      ),\n    );\n  }\n}\n\n/// Dummy widget for when the app bar changes depending on the current tab\n/// and a tab doesn't have an associated app bar.\nclass EmptyTopBar extends StatelessWidget implements PreferredSizeWidget {\n  const EmptyTopBar();\n\n  @override\n  Size get preferredSize => _preferredSize;\n\n  @override\n  Widget build(BuildContext context) {\n    return SizedBox(height: MediaQuery.paddingOf(context).top + _preferredSize.height);\n  }\n}\n\n/// An [AnimatedSwitcher] wrapper around any [PreferredSizeWidget].\n/// Used for app bars that change depending on the current page tab.\nclass TopBarAnimatedSwitcher extends StatelessWidget implements PreferredSizeWidget {\n  const TopBarAnimatedSwitcher(this.child);\n\n  final PreferredSizeWidget? child;\n\n  @override\n  Size get preferredSize => child?.preferredSize ?? const Size.fromHeight(0);\n\n  @override\n  Widget build(BuildContext context) {\n    return AnimatedSwitcher(duration: const Duration(milliseconds: 200), child: child);\n  }\n}\n"
  },
  {
    "path": "lib/widget/loaders.dart",
    "content": "import 'package:flutter/cupertino.dart';\nimport 'package:flutter/material.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/widget/shimmer.dart';\n\nclass Loader extends StatelessWidget {\n  const Loader();\n\n  @override\n  Widget build(BuildContext context) => Shimmer(\n    ShimmerItem(\n      Container(\n        width: 60,\n        height: 15,\n        decoration: BoxDecoration(\n          borderRadius: Theming.borderRadiusSmall,\n          color: ColorScheme.of(context).surfaceContainerHighest,\n        ),\n      ),\n    ),\n  );\n}\n\nclass SliverRefreshControl extends StatelessWidget {\n  const SliverRefreshControl({required this.onRefresh});\n\n  final void Function() onRefresh;\n\n  @override\n  Widget build(BuildContext context) {\n    return SliverPadding(\n      padding: .only(top: MediaQuery.paddingOf(context).top + Theming.offset),\n      sliver: CupertinoSliverRefreshControl(\n        refreshIndicatorExtent: 15,\n        refreshTriggerPullDistance: 160,\n        onRefresh: () {\n          onRefresh();\n          return Future.value();\n        },\n        builder:\n            (_, refreshState, pulledExtent, refreshTriggerPullDistance, refreshIndicatorExtent) {\n              double visibility = 0;\n              if (pulledExtent > refreshIndicatorExtent) {\n                pulledExtent -= refreshIndicatorExtent;\n                refreshTriggerPullDistance -= refreshIndicatorExtent;\n                visibility = pulledExtent / refreshTriggerPullDistance;\n                if (visibility > 1) visibility = 1;\n              }\n\n              return switch (refreshState) {\n                RefreshIndicatorMode.inactive => const SizedBox(),\n                _ => Opacity(\n                  opacity: visibility,\n                  child: const Center(child: Loader()),\n                ),\n              };\n            },\n      ),\n    );\n  }\n}\n\nclass SliverFooter extends StatelessWidget {\n  const SliverFooter({this.loading = false});\n\n  final bool loading;\n\n  @override\n  Widget build(BuildContext context) {\n    return SliverToBoxAdapter(\n      child: Center(\n        child: Padding(\n          padding: .only(\n            top: Theming.offset,\n            bottom: MediaQuery.paddingOf(context).bottom + Theming.offset,\n          ),\n          child: loading ? const Loader() : null,\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/paged_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_riverpod/flutter_riverpod.dart';\nimport 'package:flutter_riverpod/misc.dart';\nimport 'package:otraku/util/paged.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/layout/constrained_view.dart';\nimport 'package:otraku/widget/loaders.dart';\n\nclass PagedView<T> extends StatelessWidget {\n  const PagedView({\n    required this.provider,\n    required this.scrollCtrl,\n    required this.onRefresh,\n    required this.onData,\n    this.padded = true,\n    this.header,\n  });\n\n  final ProviderListenable<AsyncValue<Paged<T>>> provider;\n\n  /// If [scrollCtrl] is [PagedController], pagination will automatically work.\n  final ScrollController scrollCtrl;\n\n  /// The [invalidate] parameter is the method of [PagedView]'s [ref].\n  /// The parameter is useful, because the parent widget\n  /// may not have a [WidgetRef] at its disposal.\n  final void Function(void Function(ProviderOrFamily) invalidate) onRefresh;\n\n  /// [onData] should return a sliver widget, displaying the items.\n  final Widget Function(Paged<T>) onData;\n\n  /// If [padded] is true, the result of [onData] will be padded.\n  final bool padded;\n\n  final Widget? header;\n\n  @override\n  Widget build(BuildContext context) {\n    return Consumer(\n      builder: (context, ref, _) {\n        ref.listen<AsyncValue>(\n          provider,\n          (_, s) =>\n              s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())),\n        );\n\n        return ref\n            .watch(provider)\n            .unwrapPrevious()\n            .when(\n              loading: () => const Center(child: Loader()),\n              error: (_, _) => CustomScrollView(\n                physics: Theming.bouncyPhysics,\n                slivers: [\n                  SliverRefreshControl(onRefresh: () => onRefresh(ref.invalidate)),\n                  ?header,\n                  const SliverFillRemaining(child: Center(child: Text('Failed to load'))),\n                ],\n              ),\n              data: (data) {\n                return ConstrainedView(\n                  padded: padded,\n                  child: CustomScrollView(\n                    physics: Theming.bouncyPhysics,\n                    controller: scrollCtrl,\n                    slivers: [\n                      SliverRefreshControl(onRefresh: () => onRefresh(ref.invalidate)),\n                      ?header,\n                      data.items.isEmpty\n                          ? const SliverFillRemaining(child: Center(child: Text('No results')))\n                          : onData(data),\n                      SliverFooter(loading: data.hasNext),\n                    ],\n                  ),\n                );\n              },\n            );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/shadowed_overflow_list.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/util/theming.dart';\n\n/// A horizontal list with inner shadow\n/// on the left and right that indicates overflow.\nclass ShadowedOverflowList extends StatelessWidget {\n  const ShadowedOverflowList({\n    required this.itemCount,\n    required this.itemBuilder,\n    this.shrinkWrap = false,\n    this.itemExtent,\n  });\n\n  final int itemCount;\n  final Widget Function(BuildContext context, int i) itemBuilder;\n  final double? itemExtent;\n  final bool shrinkWrap;\n\n  @override\n  Widget build(BuildContext context) {\n    return Stack(\n      children: [\n        ListView.builder(\n          scrollDirection: Axis.horizontal,\n          padding: const .only(left: Theming.offset, right: Theming.offset / 2),\n          itemExtent: itemExtent,\n          itemCount: itemCount,\n          shrinkWrap: shrinkWrap,\n          itemBuilder: (context, i) => Padding(\n            padding: const .only(right: Theming.offset / 2),\n            child: itemBuilder(context, i),\n          ),\n        ),\n        Positioned(\n          top: 0,\n          left: 0,\n          bottom: 0,\n          child: SizedBox(\n            width: Theming.offset,\n            child: DecoratedBox(\n              decoration: BoxDecoration(\n                gradient: LinearGradient(\n                  begin: Alignment.centerLeft,\n                  end: Alignment.centerRight,\n                  colors: [\n                    ColorScheme.of(context).surface,\n                    ColorScheme.of(context).surface.withValues(alpha: 0),\n                  ],\n                ),\n              ),\n            ),\n          ),\n        ),\n        Positioned(\n          top: 0,\n          right: 0,\n          bottom: 0,\n          child: SizedBox(\n            width: Theming.offset,\n            child: DecoratedBox(\n              decoration: BoxDecoration(\n                gradient: LinearGradient(\n                  begin: Alignment.centerRight,\n                  end: Alignment.centerLeft,\n                  colors: [\n                    ColorScheme.of(context).surface,\n                    ColorScheme.of(context).surface.withValues(alpha: 0),\n                  ],\n                ),\n              ),\n            ),\n          ),\n        ),\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/sheets.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:ionicons/ionicons.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/widget/layout/adaptive_scaffold.dart';\n\n/// Used to open [DraggableScrollableSheet].\nFuture<T?> showSheet<T>(BuildContext context, Widget sheet) => showModalBottomSheet<T>(\n  context: context,\n  builder: (context) => sheet,\n  useSafeArea: true,\n  isScrollControlled: true,\n  backgroundColor: Colors.transparent,\n);\n\n/// An implementation of [DraggableScrollableSheet] with opaque background.\nclass SimpleSheet extends StatelessWidget {\n  const SimpleSheet({required this.builder, this.initialHeight});\n\n  factory SimpleSheet.list(List<Widget> children) => SimpleSheet(\n    initialHeight: Theming.normalTapTarget * children.length + Theming.offset,\n    builder: (context, scrollCtrl) => ListView(\n      controller: scrollCtrl,\n      padding: const .only(top: Theming.offset),\n      children: children,\n    ),\n  );\n\n  factory SimpleSheet.link(BuildContext context, String link, [List<Widget> children = const []]) =>\n      SimpleSheet.list([\n        ...children,\n        ListTile(\n          title: const Text('Copy Link'),\n          leading: const Icon(Ionicons.clipboard_outline),\n          onTap: () {\n            SnackBarExtension.copy(context, link);\n            Navigator.pop(context);\n          },\n        ),\n        ListTile(\n          title: const Text('Open in Browser'),\n          leading: const Icon(Ionicons.link_outline),\n          onTap: () {\n            SnackBarExtension.launch(context, link);\n            Navigator.pop(context);\n          },\n        ),\n      ]);\n\n  final Widget Function(BuildContext, ScrollController) builder;\n  final double? initialHeight;\n\n  @override\n  Widget build(BuildContext context) {\n    Widget? sheet;\n\n    final screenHeight = MediaQuery.sizeOf(context).height;\n    final bottomPadding = MediaQuery.paddingOf(context).bottom;\n    final initialFraction = initialHeight != null\n        ? (initialHeight! + bottomPadding + Theming.offset).clamp(0, screenHeight) / screenHeight\n        : 0.5;\n\n    return DraggableScrollableSheet(\n      expand: false,\n      initialChildSize: initialFraction,\n      minChildSize: initialFraction < 0.25 ? initialFraction : 0.25,\n      builder: (context, scrollCtrl) {\n        sheet ??= Container(\n          constraints: const BoxConstraints(maxWidth: Theming.windowWidthMedium),\n          decoration: BoxDecoration(\n            color: ColorScheme.of(context).surface,\n            borderRadius: const BorderRadius.vertical(top: Theming.radiusBig),\n          ),\n          child: Material(color: Colors.transparent, child: builder(context, scrollCtrl)),\n        );\n\n        return sheet!;\n      },\n    );\n  }\n}\n\n/// A wide implementation of [DraggableScrollableSheet]\n/// with a lane of buttons at the bottom.\nclass SheetWithButtonRow extends StatelessWidget {\n  const SheetWithButtonRow({required this.builder, this.buttons});\n\n  final Widget Function(BuildContext, ScrollController) builder;\n  final Widget? buttons;\n\n  @override\n  Widget build(BuildContext context) {\n    Widget? sheet;\n\n    return Padding(\n      padding: MediaQuery.viewInsetsOf(context),\n      child: DraggableScrollableSheet(\n        expand: false,\n        builder: (context, scrollCtrl) {\n          sheet ??= _sheetBody(context, scrollCtrl);\n          return sheet!;\n        },\n      ),\n    );\n  }\n\n  Widget _sheetBody(BuildContext context, ScrollController scrollCtrl) => Center(\n    child: Container(\n      constraints: const BoxConstraints(maxWidth: Theming.windowWidthMedium),\n      decoration: BoxDecoration(\n        color: ColorScheme.of(context).surface,\n        borderRadius: const BorderRadius.vertical(top: Theming.radiusBig),\n      ),\n      child: ScaffoldMessenger(\n        child: AdaptiveScaffold(\n          sheetMode: true,\n          bottomBar: buttons,\n          child: builder(context, scrollCtrl),\n        ),\n      ),\n    ),\n  );\n}\n"
  },
  {
    "path": "lib/widget/shimmer.dart",
    "content": "import 'package:flutter/material.dart';\n\nclass Shimmer extends StatefulWidget {\n  static ShimmerState? of(BuildContext context) => context.findAncestorStateOfType<ShimmerState>();\n\n  const Shimmer(this.child);\n\n  final Widget child;\n\n  @override\n  ShimmerState createState() => ShimmerState();\n}\n\nclass ShimmerState extends State<Shimmer> with SingleTickerProviderStateMixin {\n  late AnimationController _ctrl;\n  late LinearGradient _gradient;\n\n  @override\n  void initState() {\n    super.initState();\n    _ctrl = AnimationController.unbounded(vsync: this, value: 0.5)\n      ..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000));\n  }\n\n  @override\n  void didChangeDependencies() {\n    super.didChangeDependencies();\n    final back = ColorScheme.of(context).surfaceContainerHighest;\n    final hsl = HSLColor.fromColor(back);\n    final l = hsl.lightness;\n    final front = hsl.withLightness(l < 0.5 ? l + 0.1 : l - 0.1).toColor();\n\n    _gradient = LinearGradient(\n      begin: const Alignment(-1.0, -0.3),\n      end: const Alignment(1.0, 0.3),\n      stops: const [0.1, 0.3, 0.4],\n      colors: [back, front, back],\n    );\n  }\n\n  @override\n  void dispose() {\n    _ctrl.dispose();\n    super.dispose();\n  }\n\n  Listenable get animation => _ctrl;\n\n  LinearGradient get gradient => LinearGradient(\n    transform: _SlidingGradientTransform(_ctrl.value),\n    colors: _gradient.colors,\n    stops: _gradient.stops,\n    begin: _gradient.begin,\n    end: _gradient.end,\n  );\n\n  Size? get size {\n    final box = context.findRenderObject() as RenderBox?;\n    if (box == null || !box.hasSize) return null;\n    return box.size;\n  }\n\n  Offset getOffset(RenderBox descendant) =>\n      descendant.localToGlobal(Offset.zero, ancestor: context.findRenderObject() as RenderBox);\n\n  @override\n  Widget build(BuildContext context) => widget.child;\n}\n\nclass ShimmerItem extends StatefulWidget {\n  const ShimmerItem(this.child);\n\n  final Widget child;\n\n  @override\n  ShimmerItemState createState() => ShimmerItemState();\n}\n\nclass ShimmerItemState extends State<ShimmerItem> {\n  Listenable? _anim;\n\n  void _update() => setState(() {});\n\n  @override\n  void didChangeDependencies() {\n    super.didChangeDependencies();\n    _anim?.removeListener(_update);\n    _anim = Shimmer.of(context)?.animation?..addListener(_update);\n  }\n\n  @override\n  void dispose() {\n    _anim?.removeListener(_update);\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final shimmer = Shimmer.of(context);\n    if (shimmer == null) return const SizedBox();\n\n    final size = shimmer.size;\n    if (size == null) return const SizedBox();\n\n    final offset = shimmer.getOffset(context.findRenderObject() as RenderBox);\n\n    return ShaderMask(\n      blendMode: BlendMode.srcATop,\n      shaderCallback: (bounds) => shimmer.gradient.createShader(\n        Rect.fromLTWH(-offset.dx, -offset.dy, size.width, size.height),\n      ),\n      child: widget.child,\n    );\n  }\n}\n\nclass _SlidingGradientTransform extends GradientTransform {\n  const _SlidingGradientTransform(this.percent);\n\n  final double percent;\n\n  @override\n  Matrix4 transform(Rect bounds, {TextDirection? textDirection}) =>\n      Matrix4.translationValues(bounds.width * percent, 0.0, 0.0);\n}\n"
  },
  {
    "path": "lib/widget/swipe_switcher.dart",
    "content": "import 'package:flutter/material.dart';\n\n/// Rotates between [children] when swiped.\nclass SwipeSwitcher extends StatelessWidget {\n  const SwipeSwitcher({required this.index, required this.children, required this.onChanged});\n\n  final int index;\n  final List<Widget> children;\n  final void Function(int) onChanged;\n\n  static const _triggerOffset = 20.0;\n\n  @override\n  Widget build(BuildContext context) {\n    var swipeStart = 0.0;\n\n    return GestureDetector(\n      behavior: .translucent,\n      onHorizontalDragStart: (start) => swipeStart = start.globalPosition.dx,\n      onHorizontalDragUpdate: (update) {\n        if (swipeStart == 0) return;\n        final dif = swipeStart - update.globalPosition.dx;\n\n        if (dif > _triggerOffset) {\n          onChanged(index < children.length - 1 ? index + 1 : 0);\n          swipeStart = 0;\n        } else if (dif < -_triggerOffset) {\n          onChanged(index > 0 ? index - 1 : children.length - 1);\n          swipeStart = 0;\n        }\n      },\n      child: children[index],\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/table_list.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/card_extension.dart';\nimport 'package:otraku/util/theming.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\n\nclass TableList extends StatelessWidget {\n  const TableList(this.items, {required this.highContrast});\n\n  final List<(String, String)> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    if (items.isEmpty) return const SizedBox();\n\n    return CardExtension.highContrast(highContrast)(\n      child: Padding(\n        padding: const .symmetric(vertical: Theming.offset),\n        child: ListView.separated(\n          shrinkWrap: true,\n          itemCount: items.length,\n          padding: .zero,\n          physics: const NeverScrollableScrollPhysics(),\n          separatorBuilder: (context, _) => const Divider(),\n          itemBuilder: (context, i) => Row(\n            children: [\n              const SizedBox(width: Theming.offset),\n              Text(items[i].$1),\n              const SizedBox(width: Theming.offset),\n              Expanded(\n                child: GestureDetector(\n                  behavior: .opaque,\n                  onTap: () => SnackBarExtension.copy(context, items[i].$2),\n                  child: Text(items[i].$2, textAlign: .end),\n                ),\n              ),\n              const SizedBox(width: Theming.offset),\n            ],\n          ),\n        ),\n      ),\n    );\n  }\n}\n\nclass SliverTableList extends StatelessWidget {\n  const SliverTableList(this.items, {required this.highContrast});\n\n  final List<(String, String)> items;\n  final bool highContrast;\n\n  @override\n  Widget build(BuildContext context) {\n    if (items.isEmpty) return const SliverToBoxAdapter();\n\n    final colorScheme = ColorScheme.of(context);\n\n    return DecoratedSliver(\n      decoration: highContrast\n          ? BoxDecoration(\n              borderRadius: Theming.borderRadiusSmall,\n              border: .all(color: colorScheme.outlineVariant),\n            )\n          : BoxDecoration(\n              borderRadius: Theming.borderRadiusSmall,\n              color: colorScheme.surfaceContainerLow,\n              boxShadow: kElevationToShadow[1],\n            ),\n      sliver: SliverPadding(\n        padding: const .symmetric(vertical: Theming.offset),\n        sliver: SliverList.separated(\n          itemCount: items.length,\n          separatorBuilder: (context, _) => const Divider(),\n          itemBuilder: (context, i) => Row(\n            children: [\n              const SizedBox(width: Theming.offset),\n              Text(items[i].$1),\n              const SizedBox(width: Theming.offset),\n              Expanded(\n                child: GestureDetector(\n                  behavior: .opaque,\n                  onTap: () => SnackBarExtension.copy(context, items[i].$2),\n                  child: Text(items[i].$2, textAlign: .end),\n                ),\n              ),\n              const SizedBox(width: Theming.offset),\n            ],\n          ),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/text_rail.dart",
    "content": "import 'package:flutter/material.dart';\n\n/// Lists text details in a fancy way, marking\n/// the ones that come with a [true] value.\nclass TextRail extends StatelessWidget {\n  const TextRail(this.items, {this.style, this.maxLines});\n\n  final Map<String, bool> items;\n  final TextStyle? style;\n  final int? maxLines;\n\n  @override\n  Widget build(BuildContext context) {\n    if (items.isEmpty) return const SizedBox();\n\n    const spacing = TextSpan(text: ' • ');\n\n    final style = this.style ?? TextTheme.of(context).labelSmall;\n    final highlightStyle = style?.copyWith(color: ColorScheme.of(context).primary);\n\n    return Text.rich(\n      overflow: .fade,\n      maxLines: maxLines,\n      TextSpan(\n        style: style,\n        children: [\n          for (int i = 0; i < items.length - 1; i++) ...[\n            TextSpan(\n              text: items.keys.elementAt(i),\n              style: items.values.elementAt(i) ? highlightStyle : null,\n            ),\n            spacing,\n          ],\n          TextSpan(text: items.keys.last, style: items.values.last ? highlightStyle : null),\n        ],\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "lib/widget/timestamp.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:otraku/extension/date_time_extension.dart';\nimport 'package:otraku/extension/snack_bar_extension.dart';\nimport 'package:otraku/util/theming.dart';\n\nclass Timestamp extends StatelessWidget {\n  const Timestamp(\n    this.dateTime,\n    this.analogClock, {\n    this.leading = const Icon(Icons.history_rounded, size: Theming.iconSmall),\n  });\n\n  final DateTime dateTime;\n  final bool analogClock;\n  final Widget leading;\n\n  @override\n  Widget build(BuildContext context) {\n    final onTap = () => SnackBarExtension.show(\n      context,\n      dateTime.formattedDateTimeFromSeconds(analogClock),\n      canCopyText: true,\n    );\n\n    return Semantics(\n      onTap: onTap,\n      onTapHint: 'show absolute creation time',\n      tooltip: 'Creation Time',\n      child: GestureDetector(\n        onTap: onTap,\n        child: Row(\n          mainAxisSize: .min,\n          spacing: 5,\n          children: [\n            leading,\n            Text(\n              _relativeTime(),\n              style: TextTheme.of(context).labelSmall,\n              overflow: .ellipsis,\n              maxLines: 1,\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n\n  String _relativeTime() {\n    final diff = DateTime.now().difference(dateTime);\n\n    final seconds = diff.inSeconds;\n    if (seconds < 61) {\n      if (seconds > 4) return '$seconds seconds ago';\n\n      return 'just now';\n    }\n\n    final minutes = diff.inMinutes;\n    if (minutes < 61) {\n      if (minutes > 1) return '$minutes minutes ago';\n\n      return 'last minute';\n    }\n\n    final hours = diff.inHours;\n    if (hours < 25) {\n      if (hours > 1) return '$hours hours ago';\n\n      return 'last hour';\n    }\n\n    final days = diff.inDays;\n    if (days < 31) {\n      if (days > 1) return '$days days ago';\n\n      return 'yesterday';\n    }\n\n    final months = days ~/ 30;\n    if (months < 13) {\n      if (months > 1) return '$months months ago';\n\n      return 'last month';\n    }\n\n    final years = months ~/ 12;\n    if (years > 1) return '$years years ago';\n\n    return 'last year';\n  }\n}\n"
  },
  {
    "path": "pubspec.yaml",
    "content": "name: otraku\ndescription: An unofficial AniList app.\n\npublish_to: 'none'\n\nversion: 1.12.1+94\n\nenvironment:\n  sdk: '>=3.10.0 <4.0.0'\n\ndependencies:\n  flutter:\n    sdk: flutter\n\n  # State management.\n  flutter_riverpod: ^3.3.1\n\n  # Routing.\n  go_router: ^17.1.0\n\n  # Data fetching.\n  http: ^1.6.0\n\n  # Lightweight storage.\n  hive: ^2.2.3\n\n  # Access to device storage. Used for [hive] setup.\n  path_provider: ^2.1.5\n\n  # Secure storage for the access tokens.\n  flutter_secure_storage: ^10.0.0\n\n  # Markdown to HTML parsing.\n  markdown: ^7.3.1\n\n  # Used for configuring [cached_network_image].\n  flutter_cache_manager: ^3.4.1\n\n  # Image caching.\n  cached_network_image: ^3.4.1\n\n  # Opening links in the browser.\n  url_launcher: ^6.3.2\n\n  # Access to platform theme and easy theme interpolation.\n  dynamic_color: ^1.8.1\n\n  # Background tasks for notification fetching.\n  workmanager: ^0.9.0+3\n\n  # Sending device notifications.\n  flutter_local_notifications: ^21.0.0\n\n  # Parsing html into flutter widgets.\n  flutter_widget_from_html_core: ^0.17.0\n\n  # Transformation calculations.\n  vector_math: ^2.2.0\n\n  # An addition to the material icons.\n  ionicons: ^0.2.2\n\ndev_dependencies:\n  flutter_test:\n    sdk: flutter\n\n  flutter_lints: ^6.0.0\n\nflutter_icons:\n  ios: true\n  android: true\n  image_path: \"assets/icons/ios.png\"\n  adaptive_icon_background: \"#0D161E\"\n  adaptive_icon_foreground: \"assets/icons/android.png\"\n\nflutter:\n  uses-material-design: true\n\n  assets:\n    - assets/icons/about.png\n\n  fonts:\n    - family: Rubik\n      fonts:\n        - asset: assets/fonts/Rubik-VariableFont_wght.ttf\n        - asset: assets/fonts/Rubik-Italic-VariableFont_wght.ttf\n          style: italic\n"
  }
]