Repository: lotusprey/otraku Branch: main Commit: f420680e4841 Files: 291 Total size: 1.0 MB Directory structure: gitextract_qcbgjzcb/ ├── .gitattributes ├── .gitignore ├── .metadata ├── .vscode/ │ └── launch.json ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android/ │ ├── .gitignore │ ├── app/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ ├── debug/ │ │ │ └── AndroidManifest.xml │ │ ├── dev/ │ │ │ └── res/ │ │ │ ├── mipmap-anydpi-v26/ │ │ │ │ └── ic_launcher.xml │ │ │ └── values/ │ │ │ ├── colors.xml │ │ │ └── strings.xml │ │ ├── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── example/ │ │ │ │ └── otraku/ │ │ │ │ └── MainActivity.kt │ │ │ └── res/ │ │ │ ├── drawable/ │ │ │ │ └── launch_background.xml │ │ │ ├── drawable-v21/ │ │ │ │ └── launch_background.xml │ │ │ ├── mipmap-anydpi-v26/ │ │ │ │ └── ic_launcher.xml │ │ │ ├── values/ │ │ │ │ ├── colors.xml │ │ │ │ ├── strings.xml │ │ │ │ └── styles.xml │ │ │ ├── values-night/ │ │ │ │ ├── colors.xml │ │ │ │ └── styles.xml │ │ │ └── xml/ │ │ │ └── backup_rules.xml │ │ └── profile/ │ │ └── AndroidManifest.xml │ ├── build.gradle.kts │ ├── gradle/ │ │ └── wrapper/ │ │ └── gradle-wrapper.properties │ ├── gradle.properties │ └── settings.gradle.kts ├── fastlane/ │ └── metadata/ │ └── android/ │ ├── de/ │ │ ├── full_description.txt │ │ └── short_description.txt │ └── en-US/ │ ├── changelogs/ │ │ ├── 59.txt │ │ ├── 63.txt │ │ ├── 65.txt │ │ ├── 66.txt │ │ ├── 69.txt │ │ ├── 72.txt │ │ ├── 73.txt │ │ ├── 77.txt │ │ ├── 80.txt │ │ ├── 82.txt │ │ ├── 83.txt │ │ ├── 84.txt │ │ ├── 86.txt │ │ ├── 87.txt │ │ ├── 89.txt │ │ ├── 92.txt │ │ └── 94.txt │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── flutter_launcher_icons-dev.yaml ├── ios/ │ ├── .gitignore │ ├── Flutter/ │ │ ├── AppFrameworkInfo.plist │ │ ├── Debug.xcconfig │ │ ├── Profile.xcconfig │ │ └── Release.xcconfig │ ├── Podfile │ ├── Runner/ │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets/ │ │ │ ├── AppIcon-dev.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── LaunchImage.imageset/ │ │ │ ├── Contents.json │ │ │ └── README.md │ │ ├── Base.lproj/ │ │ │ ├── LaunchScreen.storyboard │ │ │ └── Main.storyboard │ │ ├── Info.plist │ │ └── Runner-Bridging-Header.h │ ├── Runner.xcodeproj/ │ │ ├── project.pbxproj │ │ ├── project.xcworkspace/ │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata/ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ │ └── xcshareddata/ │ │ └── xcschemes/ │ │ └── Runner.xcscheme │ └── Runner.xcworkspace/ │ ├── contents.xcworkspacedata │ └── xcshareddata/ │ ├── IDEWorkspaceChecks.plist │ └── WorkspaceSettings.xcsettings ├── lib/ │ ├── extension/ │ │ ├── action_chip_extension.dart │ │ ├── build_context_extension.dart │ │ ├── card_extension.dart │ │ ├── color_extension.dart │ │ ├── date_time_extension.dart │ │ ├── enum_extension.dart │ │ ├── filter_chip_extension.dart │ │ ├── future_extension.dart │ │ ├── iterable_extension.dart │ │ ├── scroll_controller_extension.dart │ │ ├── snack_bar_extension.dart │ │ └── string_extension.dart │ ├── feature/ │ │ ├── activity/ │ │ │ ├── activities_filter_model.dart │ │ │ ├── activities_filter_provider.dart │ │ │ ├── activities_model.dart │ │ │ ├── activities_provider.dart │ │ │ ├── activities_view.dart │ │ │ ├── activity_card.dart │ │ │ ├── activity_filter_sheet.dart │ │ │ ├── activity_model.dart │ │ │ ├── activity_provider.dart │ │ │ ├── activity_view.dart │ │ │ └── reply_card.dart │ │ ├── calendar/ │ │ │ ├── calendar_filter_provider.dart │ │ │ ├── calendar_filter_sheet.dart │ │ │ ├── calendar_models.dart │ │ │ ├── calendar_provider.dart │ │ │ └── calendar_view.dart │ │ ├── character/ │ │ │ ├── character_anime_view.dart │ │ │ ├── character_filter_model.dart │ │ │ ├── character_filter_provider.dart │ │ │ ├── character_floating_actions.dart │ │ │ ├── character_header.dart │ │ │ ├── character_item_grid.dart │ │ │ ├── character_item_model.dart │ │ │ ├── character_manga_view.dart │ │ │ ├── character_model.dart │ │ │ ├── character_overview_view.dart │ │ │ ├── character_provider.dart │ │ │ └── character_view.dart │ │ ├── collection/ │ │ │ ├── collection_entries_provider.dart │ │ │ ├── collection_filter_model.dart │ │ │ ├── collection_filter_provider.dart │ │ │ ├── collection_filter_view.dart │ │ │ ├── collection_floating_action.dart │ │ │ ├── collection_grid.dart │ │ │ ├── collection_list.dart │ │ │ ├── collection_models.dart │ │ │ ├── collection_provider.dart │ │ │ ├── collection_top_bar.dart │ │ │ └── collection_view.dart │ │ ├── comment/ │ │ │ ├── comment_model.dart │ │ │ ├── comment_provider.dart │ │ │ ├── comment_tile.dart │ │ │ └── comment_view.dart │ │ ├── composition/ │ │ │ ├── composition_model.dart │ │ │ ├── composition_provider.dart │ │ │ └── composition_view.dart │ │ ├── discover/ │ │ │ ├── discover_filter_model.dart │ │ │ ├── discover_filter_provider.dart │ │ │ ├── discover_floating_action.dart │ │ │ ├── discover_media_filter_view.dart │ │ │ ├── discover_media_grid.dart │ │ │ ├── discover_media_simple_grid.dart │ │ │ ├── discover_model.dart │ │ │ ├── discover_provider.dart │ │ │ ├── discover_recommendations_filter_sheet.dart │ │ │ ├── discover_recommendations_grid.dart │ │ │ ├── discover_top_bar.dart │ │ │ └── discover_view.dart │ │ ├── edit/ │ │ │ ├── edit_buttons.dart │ │ │ ├── edit_model.dart │ │ │ ├── edit_provider.dart │ │ │ ├── edit_view.dart │ │ │ └── score_field.dart │ │ ├── favorites/ │ │ │ ├── favorites_model.dart │ │ │ ├── favorites_provider.dart │ │ │ └── favorites_view.dart │ │ ├── feed/ │ │ │ ├── feed_floating_action.dart │ │ │ └── feed_top_bar.dart │ │ ├── forum/ │ │ │ ├── forum_filter_model.dart │ │ │ ├── forum_filter_provider.dart │ │ │ ├── forum_filter_view.dart │ │ │ ├── forum_model.dart │ │ │ ├── forum_provider.dart │ │ │ ├── forum_view.dart │ │ │ └── thread_item_list.dart │ │ ├── home/ │ │ │ ├── home_model.dart │ │ │ ├── home_provider.dart │ │ │ └── home_view.dart │ │ ├── media/ │ │ │ ├── media_activities_view.dart │ │ │ ├── media_characters_view.dart │ │ │ ├── media_floating_actions.dart │ │ │ ├── media_following_view.dart │ │ │ ├── media_header.dart │ │ │ ├── media_item_grid.dart │ │ │ ├── media_item_model.dart │ │ │ ├── media_models.dart │ │ │ ├── media_overview_view.dart │ │ │ ├── media_provider.dart │ │ │ ├── media_recommendations_view.dart │ │ │ ├── media_related_view.dart │ │ │ ├── media_reviews_view.dart │ │ │ ├── media_route_tile.dart │ │ │ ├── media_staff_view.dart │ │ │ ├── media_stats_view.dart │ │ │ ├── media_threads_view.dart │ │ │ └── media_view.dart │ │ ├── notification/ │ │ │ ├── notifications_filter_model.dart │ │ │ ├── notifications_filter_provider.dart │ │ │ ├── notifications_model.dart │ │ │ ├── notifications_provider.dart │ │ │ └── notifications_view.dart │ │ ├── review/ │ │ │ ├── review_grid.dart │ │ │ ├── review_header.dart │ │ │ ├── review_models.dart │ │ │ ├── review_provider.dart │ │ │ ├── review_view.dart │ │ │ ├── reviews_filter_provider.dart │ │ │ ├── reviews_filter_sheet.dart │ │ │ ├── reviews_provider.dart │ │ │ └── reviews_view.dart │ │ ├── settings/ │ │ │ ├── settings_about_view.dart │ │ │ ├── settings_app_view.dart │ │ │ ├── settings_content_view.dart │ │ │ ├── settings_model.dart │ │ │ ├── settings_notifications_view.dart │ │ │ ├── settings_provider.dart │ │ │ ├── settings_view.dart │ │ │ └── theme_preview.dart │ │ ├── social/ │ │ │ ├── social_model.dart │ │ │ ├── social_provider.dart │ │ │ └── social_view.dart │ │ ├── staff/ │ │ │ ├── staff_characters_view.dart │ │ │ ├── staff_filter_model.dart │ │ │ ├── staff_filter_provider.dart │ │ │ ├── staff_floating_actions.dart │ │ │ ├── staff_header.dart │ │ │ ├── staff_item_grid.dart │ │ │ ├── staff_item_model.dart │ │ │ ├── staff_model.dart │ │ │ ├── staff_overview_view.dart │ │ │ ├── staff_provider.dart │ │ │ ├── staff_roles_view.dart │ │ │ └── staff_view.dart │ │ ├── statistics/ │ │ │ ├── charts.dart │ │ │ ├── statistics_model.dart │ │ │ └── statistics_view.dart │ │ ├── studio/ │ │ │ ├── studio_filter_model.dart │ │ │ ├── studio_filter_provider.dart │ │ │ ├── studio_floating_actions.dart │ │ │ ├── studio_header.dart │ │ │ ├── studio_item_grid.dart │ │ │ ├── studio_item_model.dart │ │ │ ├── studio_model.dart │ │ │ ├── studio_provider.dart │ │ │ └── studio_view.dart │ │ ├── tag/ │ │ │ ├── tag_model.dart │ │ │ ├── tag_picker.dart │ │ │ └── tag_provider.dart │ │ ├── thread/ │ │ │ ├── thread_model.dart │ │ │ ├── thread_provider.dart │ │ │ └── thread_view.dart │ │ ├── user/ │ │ │ ├── user_header.dart │ │ │ ├── user_item_grid.dart │ │ │ ├── user_item_model.dart │ │ │ ├── user_model.dart │ │ │ ├── user_providers.dart │ │ │ └── user_view.dart │ │ └── viewer/ │ │ ├── persistence_model.dart │ │ ├── persistence_provider.dart │ │ ├── repository_model.dart │ │ └── repository_provider.dart │ ├── main.dart │ ├── util/ │ │ ├── background_handler.dart │ │ ├── debounce.dart │ │ ├── graphql.dart │ │ ├── markdown.dart │ │ ├── paged.dart │ │ ├── paged_controller.dart │ │ ├── routes.dart │ │ ├── theming.dart │ │ └── tile_modelable.dart │ └── widget/ │ ├── cached_image.dart │ ├── dialogs.dart │ ├── grid/ │ │ ├── chip_grid.dart │ │ ├── dual_relation_grid.dart │ │ ├── mono_relation_grid.dart │ │ └── sliver_grid_delegates.dart │ ├── html_content.dart │ ├── input/ │ │ ├── chip_selector.dart │ │ ├── date_field.dart │ │ ├── note_label.dart │ │ ├── number_field.dart │ │ ├── pill_selector.dart │ │ ├── score_label.dart │ │ ├── search_field.dart │ │ ├── stateful_tiles.dart │ │ └── year_range_picker.dart │ ├── layout/ │ │ ├── adaptive_scaffold.dart │ │ ├── constrained_view.dart │ │ ├── content_header.dart │ │ ├── dual_pane_with_tab_bar.dart │ │ ├── hiding_floating_action_button.dart │ │ ├── navigation_tool.dart │ │ └── top_bar.dart │ ├── loaders.dart │ ├── paged_view.dart │ ├── shadowed_overflow_list.dart │ ├── sheets.dart │ ├── shimmer.dart │ ├── swipe_switcher.dart │ ├── table_list.dart │ ├── text_rail.dart │ └── timestamp.dart └── pubspec.yaml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ # Auto detect text files and perform LF normalization * text=auto ================================================ FILE: .gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .build/ .buildlog/ .history .svn/ .swiftpm/ migrate_working_dir/ # IntelliJ related *.iml *.ipr *.iws .idea/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line # is commented out by default. #.vscode/ # Flutter/Dart/Pub related **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ /build/ # Web related lib/generated_plugin_registrant.dart # Symbolication related app.*.symbols # Obfuscation related app.*.map.json # Android Studio will place build artifacts here /android/app/debug /android/app/profile /android/app/release ================================================ FILE: .metadata ================================================ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # # This file should be version controlled and should not be manually edited. version: revision: f7a6a7906be96d2288f5d63a5a54c515a6e987fe channel: stable project_type: app ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Dev Android", "request": "launch", "type": "dart", "args": [ "--flavor", "dev" ], }, { "name": "Dev IOS", "request": "launch", "type": "dart", } ] } ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # Otraku An unofficial AniList app.

Google PlayIzzyOnDroid (F-Droid)Privacy Policy

The iOS .ipa and the android .apk are bundled with each Github release.

Screenshots

Building for android 1. Run `flutter build apk --split-per-abi` 2. Grab the apk release build file with your required ABI
Building for iOS 1. Run `flutter build ios --no-codesign` 2. Copy `./build/ios/iphoneos/Runner.app` into a `Payload` directory 3. Compress `Payload` and change extension to `.ipa`
================================================ FILE: analysis_options.yaml ================================================ include: package:flutter_lints/flutter.yaml linter: rules: # Often unnecessary. use_key_in_widget_constructors: false # For closures. prefer_function_declarations_over_variables: false formatter: page_width: 100 ================================================ FILE: android/.gitignore ================================================ gradle-wrapper.jar /.gradle /captures/ /gradlew /gradlew.bat /keystore.jks /keystore.properties /local.properties GeneratedPluginRegistrant.java .cxx/ ================================================ FILE: android/app/build.gradle.kts ================================================ import java.util.Properties import java.io.FileInputStream plugins { id("com.android.application") id("kotlin-android") id("dev.flutter.flutter-gradle-plugin") } val keystoreProperties = Properties() val keystorePropertiesFile = rootProject.file("keystore.properties") if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } android { namespace = "com.otraku.app" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 // Desugaring is required by flutter_local_notifications. isCoreLibraryDesugaringEnabled = true } kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } defaultConfig { applicationId = "com.otraku.app" minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } signingConfigs { create("release") { storeFile = file(rootDir.canonicalPath + "/" + keystoreProperties["releaseKeyStore"]) storePassword = keystoreProperties["releaseStorePassword"] as String keyPassword = keystoreProperties["releaseKeyPassword"] as String keyAlias = keystoreProperties["releaseKeyAlias"] as String } } buildTypes { release { signingConfig = signingConfigs.getByName("release") } } flavorDimensions += "default" productFlavors { create("dev") { dimension = "default" applicationIdSuffix = ".dev" } } } dependencies { // Desugaring is required by flutter_local_notifications. coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") } flutter { source = "../.." } ================================================ FILE: android/app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: android/app/src/dev/res/values/colors.xml ================================================ #E3F2FF ================================================ FILE: android/app/src/dev/res/values/strings.xml ================================================ Otraku ================================================ FILE: android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android/app/src/main/kotlin/com/example/otraku/MainActivity.kt ================================================ package com.otraku.app import io.flutter.embedding.android.FlutterActivity class MainActivity: FlutterActivity() { } ================================================ FILE: android/app/src/main/res/drawable/launch_background.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable-v21/launch_background.xml ================================================ ================================================ FILE: android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: android/app/src/main/res/values/colors.xml ================================================ #ffffff ================================================ FILE: android/app/src/main/res/values/strings.xml ================================================ Otraku ================================================ FILE: android/app/src/main/res/values/styles.xml ================================================ ================================================ FILE: android/app/src/main/res/values-night/colors.xml ================================================ #0D161E ================================================ FILE: android/app/src/main/res/values-night/styles.xml ================================================ ================================================ FILE: android/app/src/main/res/xml/backup_rules.xml ================================================ ================================================ FILE: android/app/src/profile/AndroidManifest.xml ================================================ ================================================ FILE: android/build.gradle.kts ================================================ allprojects { repositories { google() mavenCentral() } } val newBuildDir: Directory = rootProject.layout.buildDirectory .dir("../../build") .get() rootProject.layout.buildDirectory.value(newBuildDir) subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) } subprojects { project.evaluationDependsOn(":app") } tasks.register("clean") { delete(rootProject.layout.buildDirectory) } ================================================ FILE: android/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip ================================================ FILE: android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true ================================================ FILE: android/settings.gradle.kts ================================================ pluginManagement { val flutterSdkPath = run { val properties = java.util.Properties() file("local.properties").inputStream().use { properties.load(it) } val flutterSdkPath = properties.getProperty("flutter.sdk") require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } flutterSdkPath } includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { google() mavenCentral() gradlePluginPortal() } } plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false } include(":app") ================================================ FILE: fastlane/metadata/android/de/full_description.txt ================================================ Otraku 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! Aktuelle Funktionen: * Zeigen Sie Ihre Anime- und Manga-Listen an und bearbeiten Sie sie * Erkunde Anime, Manga, Charaktere, Mitarbeiter, Studios, Benutzer und Rezensionen * Folgende / globale Feeds anzeigen * Gefällt mir Aktivitäten und Kommentare (Kommentieren wird noch nicht unterstützt) * Wählen Sie verschiedene App-Themen * Konfigurieren Sie einige AniList-Einstellungen ================================================ FILE: fastlane/metadata/android/de/short_description.txt ================================================ Inoffizieller AniList-Client für Anime- und Manga-Tracking ================================================ FILE: fastlane/metadata/android/en-US/changelogs/59.txt ================================================ - Added calendar in discover to view and filter new episode schedules - Option for pure background in settings now not only makes dark backgrounds black, but also light backgrounds white - Fixed lazy-loading in "Following" on the media page - Other fixes and improvements ================================================ FILE: fastlane/metadata/android/en-US/changelogs/63.txt ================================================ - Collection searching goes through both titles and notes - Activity replies have a "Reply" button for automatic mentions - Tapping on markdown images opens them as a popup - Tapping on user mentions is not handled as a link, but directly opens the user page - Tapping on a ranking in a media's statistics page redirects to the discover tab with added filters - Deep linking on android, if configured in settings - List status on related media in media pages - And other visual improvements and fixes ================================================ FILE: fastlane/metadata/android/en-US/changelogs/65.txt ================================================ - Added collection filters for public/private entries and for entries with/without notes - Changed release year filter design - In fields, you can long-tap the decrement/increment buttons to set the value to min/max - Reduced minimum year in release year filter to 1917 - AniList settings are saved with a floating action button now - Fixed collection refresh forgetting the selected list - Fixed missing entries in collections and ignored name preferences - Fixed settings not reflecting account switching ================================================ FILE: fastlane/metadata/android/en-US/changelogs/66.txt ================================================ - Added collection filters for public/private entries and for entries with/without notes - Changed release year filter design - In fields, you can long-tap the decrement/increment buttons to set the value to min/max - Reduced minimum year in release year filter to 1917 - AniList settings are saved with a floating action button now - Fixed collection refresh forgetting the selected list - Fixed missing entries in collections, ignored name preferences and settings not reflecting account switching ================================================ FILE: fastlane/metadata/android/en-US/changelogs/69.txt ================================================ - AniList Markdown is supported almost fully - AniList links in markdown text are opened within the app - More markdown quick access buttons in the composition sheet - Collection previews can be filtered like full collections - User/Discover reviews can be filtered by media type - You can long-press to copy a media description - Redesigned media overview tab and other elements - Fixed bugs around deep link opening - Image popups are also cached - Other fixes and improvements ================================================ FILE: fastlane/metadata/android/en-US/changelogs/72.txt ================================================ - If your filtered collections are empty, a button can redirect you to discover with copied filters - Tag categories in the tag sheet are sorted alphabetically - Separate synonym titles on media pages - Reordered fields in the entry sheet and chapter/volume fields switch based on left-handed mode - Added an indication on whether collection/discover filters are active - Refreshable media/user pages - Fixed emojis, some filter names, collection tiles - Visual tweaks and slightly darker dark mode ================================================ FILE: fastlane/metadata/android/en-US/changelogs/73.txt ================================================ - Toggled activity/reply like buttons use the primary color - Cleaner error messages for failed connection/requests that now appear as toasts - Replaced "gradient" sheets for activity menus, discover type selection and the like with normal sheets (may still need polishing) - Fixed collection sorting - Fixed activity/reply like timeout message - Fixed home tab switching - Fixed user refresh retrying multiple times ================================================ FILE: fastlane/metadata/android/en-US/changelogs/77.txt ================================================ - Tablet support with better layout on wide screens - New studio page design - New recommendations design in the media page - Activity/Reply like icons are different depending on whether the item is liked or not - Toast messages were replaced by snackbars - Overall design has been tweaked in many areas - Fixed progress-incrementing button spamming - Fixed language order when selecting voice actor language - And more tweaks and fixes ================================================ FILE: fastlane/metadata/android/en-US/changelogs/80.txt ================================================ - In the filter sheets for collections and discover, you can set a custom default configuration - Basic AniList interactions are now supported without logging in - Easier account switching from the profile tab - You can reorder favorites and easily unfavorite them - Timestamps are now relative, but you can tap them for an absolute date - When incrementing the episode count from 0 on an entry in some lists, a pop up will offer to also change the list status ================================================ FILE: fastlane/metadata/android/en-US/changelogs/82.txt ================================================ - Chips on the media page are now a grid, not a scrollable row - Fixed the the favorites editing button appearing in others' favorites - Fixed edge cases in entry saving/removing - Fixed list statuses in media recommendations mixing up anime and manga - Fixed notification timestamps taking too much space - Shortened the snackbar timeout ================================================ FILE: fastlane/metadata/android/en-US/changelogs/83.txt ================================================ - 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. - Added a doujin filter in the discover filter sheet. - 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. ================================================ FILE: fastlane/metadata/android/en-US/changelogs/84.txt ================================================ - Added forum page with thread filters - Added thread pages with navigation, commenting, liking and subscribing (thread writing/editing is not yet done) - Added a tab on media pages with related threads - Added tabs with user's threads and comments on users' social pages - Fixed bugs related to collections, advanced scores and home page search focusing ================================================ FILE: fastlane/metadata/android/en-US/changelogs/86.txt ================================================ - Added recommendations to discover. - Improved the recommendations tab design in the media page. - Replaced left-handed mode setting with a more general "button orientation" setting. - Fixed some alignment issues in thread views. - Fixed search bar freezing when fast-switching tabs. Important: 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. ================================================ FILE: fastlane/metadata/android/en-US/changelogs/87.txt ================================================ - Fix home page tab scrolling. - Use new material page transition on Android. ================================================ FILE: fastlane/metadata/android/en-US/changelogs/89.txt ================================================ - Added activities to a tab in the media page, with the ability to filter them by people you follow. - Added custom list management (reordering not supported yet). - Improved advanced score section management. - Activities, replies, notifications and reviews are outlined when the high contrast settings is enabled (more of this in the future). - Image caching is disabled for markdown text (it bloats the cache). ================================================ FILE: fastlane/metadata/android/en-US/changelogs/92.txt ================================================ - Expanded collections now show and filter all lists at once, though you can still view individual lists. - High contrast mode now affects all tiles in the UI. - Text scaling is now unconstrained and tiles adjust size to accommodate the text. ================================================ FILE: fastlane/metadata/android/en-US/changelogs/94.txt ================================================ - Added: support for the new AniList notification types - this fixes the media and other parts of the app not loading. - Added: "Self" option in the media activity filter. - Improved: Bar charts in user/media statistics are not horizontal, more compact and more informative. - Improved: Buttons on the user page are now a grid. - Fixed: wrong status bar tint on older android versions. - Fixed: layout issues with the text being cut off. ================================================ FILE: fastlane/metadata/android/en-US/full_description.txt ================================================ Otraku aims to support most AniList features and it already covers: - Tracking media and managing/filtering collections - Browsing/Filtering media/characters/staff/studios/users/reviews/recommendations - Forum - General/User activity feeds - Composing messages - Calendar for release schedules - Customization with different themes and other options And more! ================================================ FILE: fastlane/metadata/android/en-US/short_description.txt ================================================ An unofficial AniList client for Android and iOS ================================================ FILE: fastlane/metadata/android/en-US/title.txt ================================================ Otraku ================================================ FILE: flutter_launcher_icons-dev.yaml ================================================ flutter_icons: ios: true android: true image_path: "assets/icons/ios.png" adaptive_icon_background: "#E3F2FF" adaptive_icon_foreground: "assets/icons/android.png" ================================================ FILE: ios/.gitignore ================================================ **/dgph *.mode1v3 *.mode2v3 *.moved-aside *.pbxuser *.perspectivev3 **/*sync/ .sconsign.dblite .tags* **/.vagrant/ **/DerivedData/ Icon? **/Pods/ **/.symlinks/ profile xcuserdata **/.generated/ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !default.mode1v3 !default.mode2v3 !default.pbxuser !default.perspectivev3 ================================================ FILE: ios/Flutter/AppFrameworkInfo.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable App CFBundleIdentifier io.flutter.flutter.app CFBundleInfoDictionaryVersion 6.0 CFBundleName App CFBundlePackageType FMWK CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1.0 MinimumOSVersion 13.0 ================================================ FILE: ios/Flutter/Debug.xcconfig ================================================ #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" ================================================ FILE: ios/Flutter/Profile.xcconfig ================================================ #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" ================================================ FILE: ios/Flutter/Release.xcconfig ================================================ #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" ================================================ FILE: ios/Podfile ================================================ # Uncomment this line to define a global platform for your project platform :ios, '18.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' project 'Runner', { 'Debug' => :debug, 'Profile' => :release, 'Release' => :release, } def flutter_root generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) unless File.exist?(generated_xcode_build_settings_path) raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" end File.foreach(generated_xcode_build_settings_path) do |line| matches = line.match(/FLUTTER_ROOT\=(.*)/) return matches[1].strip if matches end raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" end require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) flutter_ios_podfile_setup target 'Runner' do use_frameworks! use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) end end ================================================ FILE: ios/Runner/AppDelegate.swift ================================================ import UIKit import Flutter @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) } } ================================================ FILE: ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json ================================================ {"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"}} ================================================ FILE: ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@3x.png", "scale" : "3x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@3x.png", "scale" : "3x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@3x.png", "scale" : "3x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@2x.png", "scale" : "2x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@3x.png", "scale" : "3x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@1x.png", "scale" : "1x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@1x.png", "scale" : "1x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@1x.png", "scale" : "1x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@2x.png", "scale" : "2x" }, { "size" : "83.5x83.5", "idiom" : "ipad", "filename" : "Icon-App-83.5x83.5@2x.png", "scale" : "2x" }, { "size" : "1024x1024", "idiom" : "ios-marketing", "filename" : "Icon-App-1024x1024@1x.png", "scale" : "1x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json ================================================ { "images" : [ { "filename" : "splash_icon-2.png", "idiom" : "universal", "scale" : "1x" }, { "filename" : "splash_icon-1.png", "idiom" : "universal", "scale" : "2x" }, { "filename" : "splash_icon.png", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md ================================================ # Launch Screen Assets You can customize the launch screen with your own desired assets by replacing the image files in this directory. You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. ================================================ FILE: ios/Runner/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: ios/Runner/Base.lproj/Main.storyboard ================================================ ================================================ FILE: ios/Runner/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Otraku CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName otraku CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleSignature ???? LSApplicationQueriesSchemes https CFBundleURLTypes CFBundleTypeRole Editor CFBundleURLName otraku CFBundleURLSchemes app CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS UIBackgroundModes fetch processing UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance CADisableMinimumFrameDurationOnPhone UIApplicationSupportsIndirectInputEvents UIApplicationSceneManifest UIApplicationSupportsMultipleScenes UISceneConfigurations UIWindowSceneSessionRoleApplication UISceneClassName UIWindowScene UISceneDelegateClassName FlutterSceneDelegate UISceneConfigurationName flutter UISceneStoryboardFile Main ================================================ FILE: ios/Runner/Runner-Bridging-Header.h ================================================ #import "GeneratedPluginRegistrant.h" ================================================ FILE: ios/Runner.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 86DA8A2D06E6B47DD9E398A8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D7B8A4D25C8F2FF6456A7A6F /* Pods_Runner.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 2296DE72BA8BDC1D4CA61399 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 644309C1A146EDFAE2F149BD /* 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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C5A159429C34B5D065301B18 /* 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 = ""; }; D7B8A4D25C8F2FF6456A7A6F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 86DA8A2D06E6B47DD9E398A8 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 39FFA31997E18B17B73C8E11 /* Frameworks */ = { isa = PBXGroup; children = ( D7B8A4D25C8F2FF6456A7A6F /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; }; 7B3B2B22BC5AB865ADE1F85C /* Pods */ = { isa = PBXGroup; children = ( C5A159429C34B5D065301B18 /* Pods-Runner.debug.xcconfig */, 2296DE72BA8BDC1D4CA61399 /* Pods-Runner.release.xcconfig */, 644309C1A146EDFAE2F149BD /* Pods-Runner.profile.xcconfig */, ); path = Pods; sourceTree = ""; }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, ); name = Flutter; sourceTree = ""; }; 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 7B3B2B22BC5AB865ADE1F85C /* Pods */, 39FFA31997E18B17B73C8E11 /* Frameworks */, ); sourceTree = ""; }; 97C146EF1CF9000F007C117D /* Products */ = { isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, ); name = Products; sourceTree = ""; }; 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 97C146F11CF9000F007C117D /* Supporting Files */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; 97C146F11CF9000F007C117D /* Supporting Files */ = { isa = PBXGroup; children = ( ); name = "Supporting Files"; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( 8B4083BA08B74BDD978A39C4 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 88DF2E0B0C95DD5F1FAC1F3F /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; compatibilityVersion = "Xcode 13.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 97C146E51CF9000F007C117D; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 88DF2E0B0C95DD5F1FAC1F3F /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; 8B4083BA08B74BDD978A39C4 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( "${PODS_PODFILE_DIR_PATH}/Podfile.lock", "${PODS_ROOT}/Manifest.lock", ); name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( ); outputPaths = ( "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Run Script"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( 97C146FB1CF9000F007C117D /* Base */, ); name = Main.storyboard; sourceTree = ""; }; 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( 97C147001CF9000F007C117D /* Base */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Profile; }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = ZBL446JY27; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); PRODUCT_BUNDLE_IDENTIFIER = com.otraku.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; 97C147061CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = ZBL446JY27; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); PRODUCT_BUNDLE_IDENTIFIER = com.otraku.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; 97C147071CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = ZBL446JY27; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); PRODUCT_BUNDLE_IDENTIFIER = com.otraku.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147031CF9000F007C117D /* Debug */, 97C147041CF9000F007C117D /* Release */, 249021D3217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147061CF9000F007C117D /* Debug */, 97C147071CF9000F007C117D /* Release */, 249021D4217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } ================================================ FILE: ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: ios/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: lib/extension/action_chip_extension.dart ================================================ import 'package:flutter/material.dart'; extension ActionChipExtension on ActionChip { static final highContrast = (bool highContrast) => highContrast ? ActionChip.new : ActionChip.elevated; } ================================================ FILE: lib/extension/build_context_extension.dart ================================================ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; extension BuildContextExtension on BuildContext { void back() => canPop() ? pop() : go(Routes.home()); double lineHeight(TextStyle style) { final scaler = MediaQuery.textScalerOf(this); final scaled = scaler.scale(style.fontSize ?? Theming.fontMedium) * (style.height ?? 1); return scaled.ceilToDouble(); } } ================================================ FILE: lib/extension/card_extension.dart ================================================ import 'package:flutter/material.dart'; extension CardExtension on Card { static final highContrast = (bool highContrast) => highContrast ? Card.outlined : Card.new; } ================================================ FILE: lib/extension/color_extension.dart ================================================ import 'package:flutter/widgets.dart'; extension ColorExtension on Color { static Color? fromHexString(String src) { try { return Color(int.parse(src.substring(1, 7), radix: 16) + 0xFF000000); } catch (_) { return null; } } } ================================================ FILE: lib/extension/date_time_extension.dart ================================================ extension DateTimeExtension on DateTime { int get secondsSinceEpoch => millisecondsSinceEpoch ~/ 1000; static DateTime fromSecondsSinceEpoch(int seconds) => DateTime.fromMillisecondsSinceEpoch(seconds * 1000); static DateTime? tryFromSecondsSinceEpoch(int? seconds) => seconds != null ? fromSecondsSinceEpoch(seconds) : null; String formattedDateTimeFromSeconds(bool analogClock) => '${_weekdayName(weekday)}, $formattedDate, ${formattedTime(analogClock)}'; static DateTime? fromFuzzyDate(Map? map) { if (map?['year'] == null) return null; return DateTime(map!['year'], map['month'] ?? 1, map['day'] ?? 1); } static String? fuzzyDateString(Map? map) { if (map == null || map['year'] == null) return null; final year = map['year']; final month = map['month']; final day = map['day']; return '${day != null ? '$day ' : ''}' '${month != null ? '${monthName(month)} ' : ''}' '$year'; } Map get fuzzyDate => {'year': year, 'month': month, 'day': day}; String get formattedWithWeekDay => '$formattedDate - ${_weekdayName(weekday)}'; String get formattedDate => '$day ${monthName(month)} $year'; String formattedTime(bool analogClock) { if (analogClock) { final (overflows, realHour) = hour > 12 ? (true, hour - 12) : (false, hour); return '${realHour < 10 ? 0 : ''}$realHour' ':${minute < 10 ? 0 : ''}$minute ' '${overflows ? 'PM' : 'AM'}'; } return '${hour <= 9 ? 0 : ''}$hour' ':${minute <= 9 ? 0 : ''}$minute'; } String get timeUntil { int minutes = difference(DateTime.now()).inMinutes; int hours = minutes ~/ 60; minutes %= 60; int days = hours ~/ 24; hours %= 24; return '${days < 1 ? "" : "${days}d "}' '${hours < 1 ? "" : "${hours}h "}' '${minutes < 1 ? "" : "${minutes}m"}'; } static String monthName(int month) => switch (month) { 1 => 'Jan', 2 => 'Feb', 3 => 'Mar', 4 => 'Apr', 5 => 'May', 6 => 'Jun', 7 => 'Jul', 8 => 'Aug', 9 => 'Sep', 10 => 'Oct', 11 => 'Nov', _ => 'Dec', }; static String _weekdayName(int weekday) => switch (weekday) { 1 => 'Mon', 2 => 'Tue', 3 => 'Wed', 4 => 'Thu', 5 => 'Fri', 6 => 'Sat', _ => 'Sun', }; } ================================================ FILE: lib/extension/enum_extension.dart ================================================ extension EnumExtension on Iterable { T? getOrNull(int? index) { if (index != null && index >= 0 && index < length) { return elementAt(index); } return null; } T getOrFirst(int? index) { if (index != null && index >= 0 && index < length) { return elementAt(index); } return first; } } ================================================ FILE: lib/extension/filter_chip_extension.dart ================================================ import 'package:flutter/material.dart'; extension FilterChipExtension on FilterChip { static final highContrast = (bool highContrast) => highContrast ? FilterChip.new : FilterChip.elevated; } ================================================ FILE: lib/extension/future_extension.dart ================================================ extension FutureExtension on Future { Future getErrorOrNull() => then((_) => null, onError: (e) => e); } ================================================ FILE: lib/extension/iterable_extension.dart ================================================ extension IterableExtension on Iterable { E? firstWhereOrNull(bool Function(E) test) { for (E element in this) { if (test(element)) return element; } return null; } } ================================================ FILE: lib/extension/scroll_controller_extension.dart ================================================ import 'package:flutter/widgets.dart'; extension ScrollControllerExtension on ScrollController { /// Scroll to the top with an animation. Future scrollToTop() async { if (!hasClients || positions.last.pixels <= 0) return; if (positions.last.pixels > 100) positions.last.jumpTo(100); await positions.last.animateTo( 0, duration: const Duration(milliseconds: 200), curve: Curves.decelerate, ); } } ================================================ FILE: lib/extension/snack_bar_extension.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; extension SnackBarExtension on SnackBar { static ScaffoldFeatureController show( BuildContext context, String text, { bool canCopyText = false, }) { return ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(text), behavior: SnackBarBehavior.floating, duration: const Duration(milliseconds: 2000), persist: false, action: canCopyText ? SnackBarAction( label: 'Copy', onPressed: () => Clipboard.setData(ClipboardData(text: text)), ) : null, ), ); } /// Copy [text] to clipboard and notify with a snackbar. static void copy(BuildContext context, String text) async { await Clipboard.setData(ClipboardData(text: text)); if (context.mounted) show(context, 'Copied'); } /// Launch [link] in the browser or show a snackbar if unsuccessful. static Future launch(BuildContext context, String link) async { try { final ok = await launchUrl( Uri.parse(link), mode: link.startsWith("https://anilist.co") ? LaunchMode.inAppBrowserView : LaunchMode.externalApplication, ); if (ok) return true; } catch (_) {} if (context.mounted) show(context, 'Could not open link'); return false; } } ================================================ FILE: lib/extension/string_extension.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; extension StringExtension on String { static String? languageToCode(String? language) => switch (language) { 'Japanese' => 'JP', 'Chinese' => 'CN', 'Korean' => 'KR', 'French' => 'FR', 'Spanish' => 'ES', 'Italian' => 'IT', 'Portuguese' => 'PT', 'German' => 'DE', _ => null, }; static String? tryNoScreamingSnakeCase(dynamic str) => str is String ? str.noScreamingSnakeCase : null; static final _ampersand = '&'.codeUnitAt(0); static final _hashtag = '#'.codeUnitAt(0); static final _semicolon = ';'.codeUnitAt(0); /// AniList can't handle some unicode characters, so before uploading text, /// symbols that are too big should be represented as HTML character entity /// references. Important primarily for emojis, hence the name. String get withParsedEmojis { final parsedRunes = []; for (final c in runes.toList()) { if (c > 0xFFFF) { parsedRunes.addAll([_ampersand, _hashtag, ...c.toString().codeUnits, _semicolon]); } else { parsedRunes.add(c); } } return String.fromCharCodes(parsedRunes); } String get noScreamingSnakeCase => splitMapJoin( '_', onMatch: (_) => ' ', onNonMatch: (s) => s[0].toUpperCase() + s.substring(1).toLowerCase(), ); static String? fromFuzzyDate(Map? map) { if (map?['year'] == null) return null; final year = map!['year']; final month = map['month']; final day = map['day']; return '${day != null ? '$day ' : ''}${month != null ? '${DateTimeExtension.monthName(month)} ' : ''}$year'; } } ================================================ FILE: lib/feature/activity/activities_filter_model.dart ================================================ import 'package:otraku/extension/enum_extension.dart'; sealed class ActivitiesFilter { const ActivitiesFilter(); ActivitiesFilter copy(); Map toGraphQlVariables(); } class HomeActivitiesFilter extends ActivitiesFilter { const HomeActivitiesFilter( this.viewerId, this.onFollowing, this.withViewerActivities, this.typeIn, ); factory HomeActivitiesFilter.empty() => const HomeActivitiesFilter(null, false, false, [.animeStatus, .mangaStatus, .status]); factory HomeActivitiesFilter.fromPersistenceMap(Map map, int? viewerId) { final List typeIn = map['activityTypeIn'] ?? [ActivityType.status.index, ActivityType.animeStatus.index, ActivityType.mangaStatus.index]; return HomeActivitiesFilter( viewerId, map['onFollowing'] ?? false, map['withViewerActivities'] ?? false, typeIn.map((index) => ActivityType.values.getOrFirst(index)).toList(), ); } final int? viewerId; final bool onFollowing; final bool withViewerActivities; final List typeIn; @override HomeActivitiesFilter copy() => HomeActivitiesFilter(viewerId, onFollowing, withViewerActivities, [...typeIn]); HomeActivitiesFilter copyWith({ bool? onFollowing, bool? withViewerActivities, List? typeIn, }) => HomeActivitiesFilter( viewerId, onFollowing ?? this.onFollowing, withViewerActivities ?? this.withViewerActivities, typeIn ?? this.typeIn, ); @override Map toGraphQlVariables() => { 'isFollowing': onFollowing, if (!onFollowing) 'hasRepliesOrText': true, if (!withViewerActivities && viewerId != null) 'userIdNot': viewerId, 'typeIn': typeIn.map((t) => t.value).toList(), }; Map toPersistenceMap() => { 'onFollowing': onFollowing, 'withViewerActivities': withViewerActivities, 'activityTypeIn': typeIn.map((a) => a.index).toList(), }; } class UserActivitiesFilter extends ActivitiesFilter { const UserActivitiesFilter(this.userId, this.typeIn); final int userId; final List typeIn; @override UserActivitiesFilter copy() => UserActivitiesFilter(userId, [...typeIn]); UserActivitiesFilter copyWithTypeIn(List typeIn) => UserActivitiesFilter(userId, typeIn); @override Map toGraphQlVariables() => { 'userId': userId, 'typeIn': typeIn.map((t) => t.value).toList(), }; } class MediaActivitiesFilter extends ActivitiesFilter { const MediaActivitiesFilter(this.socialGroup, this.mediaId, this.viewerId); factory MediaActivitiesFilter.empty() => const MediaActivitiesFilter(.global, 0, null); final int mediaId; final ActivitySocialGroup socialGroup; final int? viewerId; @override MediaActivitiesFilter copy() => MediaActivitiesFilter(socialGroup, mediaId, viewerId); MediaActivitiesFilter copyWith({ ActivitySocialGroup? socialGroup, int? mediaId, (int?,)? viewerId, }) => MediaActivitiesFilter( socialGroup ?? this.socialGroup, mediaId ?? this.mediaId, viewerId != null ? viewerId.$1 : this.viewerId, ); @override Map toGraphQlVariables() => { 'mediaId': mediaId, ...switch (socialGroup) { .global => const {}, .followed => const {'isFollowing': true}, .self => viewerId != null ? {'userId': viewerId} : {'isFollowing': true}, }, }; Map toPersistenceMap() => {'socialGroup': socialGroup.index}; static MediaActivitiesFilter fromPersistence( Map map, int mediaId, int? viewerId, ) => MediaActivitiesFilter( ActivitySocialGroup.values.getOrFirst(map['socialGroup']), mediaId, viewerId, ); } enum ActivityType { status('Statuses', 'TEXT'), animeStatus('Anime Progress', 'ANIME_LIST'), mangaStatus('Manga Progress', 'MANGA_LIST'), message('Messages', 'MESSAGE'); const ActivityType(this.label, this.value); final String label; final String value; } enum ActivitySocialGroup { global, followed, self } ================================================ FILE: lib/feature/activity/activities_filter_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/activity/activities_filter_model.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; final activitiesFilterProvider = NotifierProvider.autoDispose .family( ActivitiesFilterNotifier.new, ); class ActivitiesFilterNotifier extends Notifier { ActivitiesFilterNotifier(this.arg); final ActivitiesTag arg; @override ActivitiesFilter build() => switch (arg) { HomeActivitiesTag _ => ref.watch(persistenceProvider.select((s) => s.homeActivitiesFilter)), UserActivitiesTag(:final userId) => UserActivitiesFilter(userId, ActivityType.values), MediaActivitiesTag(:final mediaId) => ref .watch(persistenceProvider.select((s) => s.mediaActivitiesFilter)) .copyWith(mediaId: mediaId, viewerId: (ref.watch(viewerIdProvider),)), }; @override set state(ActivitiesFilter newState) { if (state == newState) return; switch (newState) { case HomeActivitiesFilter homeActivitiesFilter: ref.read(persistenceProvider.notifier).setHomeActivitiesFilter(homeActivitiesFilter); case MediaActivitiesFilter mediaActivitiesFilter: ref.read(persistenceProvider.notifier).setMediaActivitiesFilter(mediaActivitiesFilter); case UserActivitiesFilter _: super.state = newState; } } } ================================================ FILE: lib/feature/activity/activities_model.dart ================================================ sealed class ActivitiesTag { const ActivitiesTag(); String toQueryParam() => switch (this) { HomeActivitiesTag() => 'home', UserActivitiesTag(:final userId) => 'user:$userId', MediaActivitiesTag(:final mediaId) => 'media:$mediaId', }; static ActivitiesTag? fromQueryParam(String param) { if (param == 'home') { return HomeActivitiesTag.instance; } else if (param.startsWith('user:')) { final userId = int.tryParse(param.substring(5)); if (userId != null) { return UserActivitiesTag(userId); } } else if (param.startsWith('media:')) { final mediaId = int.tryParse(param.substring(6)); if (mediaId != null) { return MediaActivitiesTag(mediaId); } } return null; } } class HomeActivitiesTag extends ActivitiesTag { const HomeActivitiesTag._(); static const instance = HomeActivitiesTag._(); } class UserActivitiesTag extends ActivitiesTag { const UserActivitiesTag(this.userId); final int userId; @override bool operator ==(Object other) => other is UserActivitiesTag && userId == other.userId; @override int get hashCode => userId.hashCode; } class MediaActivitiesTag extends ActivitiesTag { const MediaActivitiesTag(this.mediaId); final int mediaId; @override bool operator ==(Object other) => other is MediaActivitiesTag && mediaId == other.mediaId; @override int get hashCode => mediaId.hashCode; } ================================================ FILE: lib/feature/activity/activities_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/feature/activity/activities_filter_model.dart'; import 'package:otraku/feature/activity/activities_filter_provider.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/feature/activity/activity_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/util/graphql.dart'; final activitiesProvider = AsyncNotifierProvider.autoDispose .family, ActivitiesTag>(ActivitiesNotifier.new); class ActivitiesNotifier extends AsyncNotifier> { ActivitiesNotifier(this.arg); final ActivitiesTag arg; int? _viewerId; late ActivitiesFilter _filter; // Used to skip activities when fetching outdated pages. int? _lastId; @override FutureOr> build() { // The home feed and the media feeds are lazy-loaded. The home feed is never disposed, // while the media feeds are disposed only when the media page is popped. if (arg is HomeActivitiesTag || arg is MediaActivitiesTag) { ref.keepAlive(); } _lastId = null; _filter = ref.watch(activitiesFilterProvider(arg)); _viewerId = ref.watch(viewerIdProvider); return _fetch(const Paged()); } Future fetch() async { final oldState = state.value ?? const Paged(); if (!oldState.hasNext) return; state = await AsyncValue.guard(() => _fetch(oldState)); } Future> _fetch(Paged oldState) async { final data = await ref.read(repositoryProvider).request(GqlQuery.activityPage, { 'page': oldState.next, ..._filter.toGraphQlVariables(), }); final imageQuality = ref.read(persistenceProvider).options.imageQuality; final items = []; for (final a in data['Page']['activities']) { if (_lastId != null && a['id'] >= _lastId) continue; final item = Activity.maybe(a, _viewerId, imageQuality); if (item != null) items.add(item); } if (data['Page']['activities'].isNotEmpty) { _lastId = data['Page']['activities'].last['id']; } return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false); } void prepend(Map map) { final value = state.value; if (value == null) return; final activity = Activity.maybe( map, _viewerId, ref.read(persistenceProvider).options.imageQuality, ); if (activity == null) return; state = AsyncValue.data( Paged(items: [activity, ...value.items], hasNext: value.hasNext, next: value.next), ); } void replace(Activity activity) { final value = state.value; if (value == null) return; for (int i = 0; i < value.items.length; i++) { if (value.items[i].id == activity.id) { value.items[i] = activity; state = AsyncValue.data( Paged(items: value.items, hasNext: value.hasNext, next: value.next), ); return; } } } Future toggleLike(Activity activity) async { final err = await ref.read(repositoryProvider).request(GqlMutation.toggleLike, { 'id': activity.id, 'type': 'ACTIVITY', }).getErrorOrNull(); if (err != null) return err; replace(activity); return null; } Future toggleSubscription(Activity activity) async { final err = await ref.read(repositoryProvider).request(GqlMutation.toggleActivitySubscription, { 'id': activity.id, 'subscribe': activity.isSubscribed, }).getErrorOrNull(); if (err != null) return err; replace(activity); return null; } Future togglePin(Activity activity) async { final err = await ref.read(repositoryProvider).request(GqlMutation.toggleActivityPin, { 'id': activity.id, 'pinned': activity.isPinned, }).getErrorOrNull(); if (err != null) return err; final value = state.value; if (value == null) return null; for (int i = 0; i < value.items.length; i++) { if (value.items[i].id == activity.id) { // Unpin previously pinned activity. if (value.items.length > 1) { value.items[0].isPinned = false; } // Move newly pinned activity to the top. for (int j = i - 1; j >= 0; j--) { value.items[j + 1] = value.items[j]; } value.items[0] = activity; state = AsyncValue.data( Paged(items: value.items, hasNext: value.hasNext, next: value.next), ); break; } } return null; } Future remove(Activity activity) async { final err = await ref.read(repositoryProvider).request(GqlMutation.deleteActivity, { 'id': activity.id, }).getErrorOrNull(); if (err != null) return err; final value = state.value; if (value == null) return null; for (int i = 0; i < value.items.length; i++) { if (value.items[i].id == activity.id) { value.items.removeAt(i); state = AsyncValue.data( Paged(items: value.items, hasNext: value.hasNext, next: value.next), ); break; } } return null; } } ================================================ FILE: lib/feature/activity/activities_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/feature/activity/activity_filter_sheet.dart'; import 'package:otraku/feature/activity/activities_provider.dart'; import 'package:otraku/feature/activity/activity_card.dart'; import 'package:otraku/feature/composition/composition_model.dart'; import 'package:otraku/feature/composition/composition_view.dart'; import 'package:otraku/feature/settings/settings_provider.dart'; import 'package:otraku/feature/activity/activity_model.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/widget/paged_view.dart'; class ActivitiesView extends ConsumerStatefulWidget { const ActivitiesView(this.tag); final UserActivitiesTag tag; @override ConsumerState createState() => _ActivitiesViewState(); } class _ActivitiesViewState extends ConsumerState { late final _scrollCtrl = PagedController( loadMore: () => ref.read(activitiesProvider(widget.tag).notifier).fetch(), ); @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final viewerId = ref.watch(viewerIdProvider); final userId = widget.tag.userId; final floatingAction = viewerId != null ? HidingFloatingActionButton( key: const Key('post'), scrollCtrl: _scrollCtrl, child: FloatingActionButton( tooltip: userId == viewerId ? 'New Post' : 'New Message', child: const Icon(Icons.edit_outlined), onPressed: () => showSheet( context, CompositionView( tag: userId == viewerId ? const StatusActivityCompositionTag(id: null) : MessageActivityCompositionTag(id: null, recipientId: userId), onSaved: (map) => ref.read(activitiesProvider(widget.tag).notifier).prepend(map), ), ), ), ) : null; return AdaptiveScaffold( topBar: TopBar( title: 'Activities', trailing: [ IconButton( tooltip: 'Filter', icon: const Icon(Ionicons.funnel_outline), onPressed: () => showActivityFilterSheet(context, ref, widget.tag), ), ], ), floatingAction: floatingAction, child: ActivitiesSubView(widget.tag, _scrollCtrl), ); } } class ActivitiesSubView extends StatelessWidget { const ActivitiesSubView(this.tag, this.scrollCtrl); final ActivitiesTag tag; final ScrollController scrollCtrl; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { final viewerId = ref.watch(viewerIdProvider); final options = ref.watch(persistenceProvider.select((s) => s.options)); return PagedView( provider: activitiesProvider( tag, ).select((s) => s.unwrapPrevious().whenData((data) => data)), scrollCtrl: scrollCtrl, onRefresh: (invalidate) { invalidate(activitiesProvider(tag)); if (tag is HomeActivitiesTag) { ref.read(settingsProvider.notifier).refetchUnread(); } }, onData: (data) => SliverList( delegate: SliverChildBuilderDelegate( childCount: data.items.length, (context, i) => ActivityCard( withHeader: true, analogClock: options.analogClock, highContrast: options.highContrast, activity: data.items[i], footer: ActivityFooter( viewerId: viewerId, activity: data.items[i], toggleLike: () => ref.read(activitiesProvider(tag).notifier).toggleLike(data.items[i]), toggleSubscription: () => ref.read(activitiesProvider(tag).notifier).toggleSubscription(data.items[i]), togglePin: () => ref.read(activitiesProvider(tag).notifier).togglePin(data.items[i]), remove: () => ref.read(activitiesProvider(tag).notifier).remove(data.items[i]), onEdited: (map) { final activity = Activity.maybe(map, viewerId, options.imageQuality); if (activity == null) return; ref.read(activitiesProvider(tag).notifier).replace(activity); }, reply: () => context.push(Routes.activity(data.items[i].id, tag)), ), ), ), ), ); }, ); } } ================================================ FILE: lib/feature/activity/activity_card.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/activity/activity_model.dart'; import 'package:otraku/feature/composition/composition_model.dart'; import 'package:otraku/feature/composition/composition_view.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/widget/timestamp.dart'; class ActivityCard extends StatelessWidget { const ActivityCard({ required this.activity, required this.footer, required this.withHeader, required this.analogClock, required this.highContrast, }); final Activity activity; final ActivityFooter footer; final bool withHeader; final bool analogClock; final bool highContrast; @override Widget build(BuildContext context) { final body = CardExtension.highContrast(highContrast)( margin: const .only(bottom: Theming.offset), child: Padding( padding: const .only(top: Theming.offset, left: Theming.offset, right: Theming.offset), child: Column( children: [ if (activity is MediaActivity) _ActivityMediaBox(activity as MediaActivity) else HtmlContent(activity.text), Row( mainAxisAlignment: .spaceBetween, spacing: 5, children: [ Flexible(child: Timestamp(activity.createdAt, analogClock)), footer, ], ), ], ), ), ); if (!withHeader) return body; const avatarSize = 50.0; return Column( crossAxisAlignment: .start, children: [ Row( children: [ Flexible( child: GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.user(activity.authorId, activity.authorAvatarUrl)), child: Row( mainAxisSize: .min, spacing: Theming.offset, children: [ ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage( activity.authorAvatarUrl, height: avatarSize, width: avatarSize, ), ), Flexible(child: Text(activity.authorName, overflow: .ellipsis, maxLines: 1)), ], ), ), ), ...switch (activity) { MessageActivity message => [ if (message.isPrivate) const Padding( padding: .only(left: Theming.offset), child: Icon(Ionicons.eye_off_outline), ), const Padding( padding: .symmetric(horizontal: Theming.offset), child: Icon(Icons.arrow_right_alt), ), GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.user(message.recipientId, message.recipientAvatarUrl)), child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage( message.recipientAvatarUrl, height: avatarSize, width: avatarSize, ), ), ), ], _ when activity.isPinned => const [ Padding( padding: .only(left: Theming.offset), child: Icon(Icons.push_pin_outlined), ), ], _ => const [], }, ], ), const SizedBox(height: 5), body, ], ); } } class _ActivityMediaBox extends StatelessWidget { const _ActivityMediaBox(this.item); final MediaActivity item; @override Widget build(BuildContext context) { final textTheme = TextTheme.of(context); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!); final height = bodyMediumLineHeight * 3 + labelMediumLineHeight + 5; return MediaRouteTile( id: item.mediaId, imageUrl: item.coverUrl, child: SizedBox( height: height, child: Row( children: [ ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage(item.coverUrl, width: height / Theming.coverHtoWRatio), ), Expanded( child: Padding( padding: const .symmetric(horizontal: Theming.offset), child: Column( mainAxisAlignment: .spaceEvenly, crossAxisAlignment: .start, spacing: 5, children: [ Text.rich( TextSpan( children: [ TextSpan(text: item.text, style: textTheme.labelMedium), TextSpan(text: item.title, style: textTheme.bodyMedium), ], ), overflow: .ellipsis, maxLines: 3, ), if (item.format != null) Text( item.format!, style: textTheme.labelMedium, overflow: .ellipsis, maxLines: 1, ), ], ), ), ), ], ), ), ); } } class ActivityFooter extends StatefulWidget { const ActivityFooter({ required this.viewerId, required this.activity, required this.remove, required this.togglePin, required this.toggleLike, required this.toggleSubscription, required this.reply, required this.onEdited, }); final int? viewerId; final Activity activity; final Future Function() remove; final Future Function() toggleLike; final Future Function() toggleSubscription; final Future Function() togglePin; final Future Function()? reply; final void Function(Map)? onEdited; @override State createState() => _ActivityFooterState(); } class _ActivityFooterState extends State { @override Widget build(BuildContext context) { final activity = widget.activity; return Row( children: [ SizedBox( height: 40, child: Tooltip( message: 'More', child: InkResponse( radius: Theming.radiusSmall.x, onTap: _showMoreSheet, child: const Icon(Ionicons.ellipsis_horizontal, size: Theming.iconSmall), ), ), ), const SizedBox(width: Theming.offset), SizedBox( height: 40, child: Tooltip( message: 'Replies', child: InkResponse( radius: Theming.radiusSmall.x, onTap: widget.reply, child: Row( children: [ Text(activity.replyCount.toString(), style: TextTheme.of(context).labelSmall), const SizedBox(width: 5), const Icon(Icons.reply_all_rounded, size: Theming.iconSmall), ], ), ), ), ), const SizedBox(width: Theming.offset), SizedBox( height: 40, child: Tooltip( message: !activity.isLiked ? 'Like' : 'Unlike', child: InkResponse( radius: Theming.radiusSmall.x, onTap: _toggleLike, child: Row( children: [ Text( activity.likeCount.toString(), style: !activity.isLiked ? TextTheme.of(context).labelSmall : TextTheme.of( context, ).labelSmall!.copyWith(color: ColorScheme.of(context).primary), ), const SizedBox(width: 5), Icon( !widget.activity.isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded, size: Theming.iconSmall, color: activity.isLiked ? ColorScheme.of(context).primary : null, ), ], ), ), ), ), ], ); } /// Show a sheet with additional options. void _showMoreSheet() { final activity = widget.activity; showSheet( context, Consumer( builder: (context, ref, _) { final ownershipButtons = []; if (activity.isOwned) { if (activity is! MessageActivity) { ownershipButtons.add( ListTile( title: activity.isPinned ? const Text('Unpin') : const Text('Pin'), leading: activity.isPinned ? const Icon(Icons.push_pin) : const Icon(Icons.push_pin_outlined), onTap: _togglePin, ), ); } if (activity.authorId == widget.viewerId) { switch (activity) { case StatusActivity _: ownershipButtons.add( ListTile( title: const Text('Edit'), leading: const Icon(Icons.edit_outlined), onTap: () => showSheet( context, CompositionView( tag: StatusActivityCompositionTag(id: activity.id), onSaved: (map) { widget.onEdited?.call(map); Navigator.pop(context); }, ), ), ), ); case MessageActivity _: ownershipButtons.add( ListTile( title: const Text('Edit'), leading: const Icon(Icons.edit_outlined), onTap: () => showSheet( context, CompositionView( tag: MessageActivityCompositionTag( id: activity.id, recipientId: activity.recipientId, ), onSaved: (map) { widget.onEdited?.call(map); Navigator.pop(context); }, ), ), ), ); case MediaActivity _: break; } } ownershipButtons.add( ListTile( title: const Text('Delete'), leading: const Icon(Ionicons.trash_outline), onTap: () => ConfirmationDialog.show( context, title: 'Delete?', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: _remove, ), ), ); } return SimpleSheet.link(context, activity.siteUrl, [ ...ownershipButtons, ListTile( title: !activity.isSubscribed ? const Text('Subscribe') : const Text('Unsubscribe'), leading: !activity.isSubscribed ? const Icon(Ionicons.notifications_outline) : const Icon(Ionicons.notifications_off_outline), onTap: _toggleSubscription, ), ]); }, ), ); } void _toggleLike() async { final activity = widget.activity; final isLiked = activity.isLiked; setState(() { activity.isLiked = !isLiked; activity.likeCount += isLiked ? -1 : 1; }); final err = await widget.toggleLike(); if (err == null) return; setState(() { activity.isLiked = isLiked; activity.likeCount += isLiked ? 1 : -1; }); if (mounted) SnackBarExtension.show(context, err.toString()); } void _toggleSubscription() { final activity = widget.activity; activity.isSubscribed = !activity.isSubscribed; widget.toggleSubscription().then((err) { if (err == null) { if (mounted) Navigator.pop(context); return; } activity.isSubscribed = !activity.isSubscribed; if (mounted) { SnackBarExtension.show(context, err.toString()); Navigator.pop(context); } }); } void _togglePin() { final activity = widget.activity; activity.isPinned = !activity.isPinned; widget.togglePin().then((err) { if (err == null) { if (mounted) Navigator.pop(context); return; } activity.isPinned = !activity.isPinned; if (mounted) { SnackBarExtension.show(context, err.toString()); Navigator.pop(context); } }); } void _remove() { widget.remove().then((err) { if (err == null) { if (mounted) Navigator.pop(context); return; } if (mounted) { SnackBarExtension.show(context, err.toString()); Navigator.pop(context); } }); } } ================================================ FILE: lib/feature/activity/activity_filter_sheet.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/activity/activities_filter_model.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/feature/activity/activities_filter_provider.dart'; void showActivityFilterSheet(BuildContext context, WidgetRef ref, ActivitiesTag tag) { ActivitiesFilter filter = ref.read(activitiesFilterProvider(tag)); double initialHeight = Theming.normalTapTarget * ActivityType.values.length + Theming.offset; if (filter is HomeActivitiesFilter) { initialHeight += Theming.normalTapTarget * 2.5; } showSheet( context, SimpleSheet( initialHeight: initialHeight, builder: (context, scrollCtrl) => _FilterList(filter: filter, onChanged: (v) => filter = v, scrollCtrl: scrollCtrl), ), ).then((_) { ref.read(activitiesFilterProvider(tag).notifier).state = filter; }); } class _FilterList extends StatefulWidget { const _FilterList({required this.filter, required this.onChanged, required this.scrollCtrl}); final ActivitiesFilter filter; final void Function(ActivitiesFilter) onChanged; final ScrollController scrollCtrl; @override State<_FilterList> createState() => _FilterListState(); } class _FilterListState extends State<_FilterList> { late var _filter = widget.filter.copy(); @override Widget build(BuildContext context) { final typeIn = switch (_filter) { HomeActivitiesFilter(:final typeIn) => typeIn, UserActivitiesFilter(:final typeIn) => typeIn, MediaActivitiesFilter _ => [], }; return ListView( controller: widget.scrollCtrl, physics: Theming.bouncyPhysics, padding: const .symmetric(vertical: Theming.offset), children: [ for (final a in ActivityType.values) CheckboxListTile( title: Text(a.label), value: typeIn.contains(a), onChanged: (val) { setState(() { if (val == true) { typeIn.add(a); } else if (val == false) { typeIn.remove(a); } }); widget.onChanged(_filter.copy()); }, ), ...switch (_filter) { UserActivitiesFilter _ || MediaActivitiesFilter _ => const [], HomeActivitiesFilter filter => [ const Divider(), CheckboxListTile( title: const Text('My Activities'), value: filter.withViewerActivities, onChanged: (v) { setState(() => _filter = filter.copyWith(withViewerActivities: v!)); widget.onChanged(_filter.copy()); }, ), Padding( padding: const .only( top: Theming.offset, left: Theming.offset, right: Theming.offset, ), child: SegmentedButton( segments: const [ ButtonSegment( value: true, label: Text('Following'), icon: Icon(Ionicons.people_outline), ), ButtonSegment( value: false, label: Text('Global'), icon: Icon(Ionicons.planet_outline), ), ], selected: {filter.onFollowing}, onSelectionChanged: (v) { setState(() => _filter = filter.copyWith(onFollowing: v.first)); widget.onChanged(_filter.copy()); }, ), ), ], }, ], ); } } ================================================ FILE: lib/feature/activity/activity_model.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/util/markdown.dart'; class ExpandedActivity { ExpandedActivity(this.activity, this.replies); final Activity activity; final Paged replies; } sealed class Activity { Activity({ required this.id, required this.authorId, required this.authorName, required this.authorAvatarUrl, required this.createdAt, required this.text, required this.siteUrl, required this.isOwned, required this.replyCount, required this.likeCount, required this.isLiked, required this.isSubscribed, required this.isPinned, }); static Activity? maybe(Map map, int? viewerId, ImageQuality imageQuality) { try { switch (map['type']) { case 'TEXT': if (map['user'] == null) return null; return StatusActivity( id: map['id'], authorId: map['user']['id'], authorName: map['user']['name'], authorAvatarUrl: map['user']['avatar']['large'], siteUrl: map['siteUrl'], text: parseMarkdown(map['text'] ?? ''), createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']), isOwned: map['user']['id'] == viewerId, replyCount: map['replyCount'] ?? 0, likeCount: map['likeCount'] ?? 0, isLiked: map['isLiked'] ?? false, isSubscribed: map['isSubscribed'] ?? false, isPinned: map['isPinned'] ?? false, ); case 'MESSAGE': if (map['messenger'] == null || map['recipient'] == null) return null; return MessageActivity( id: map['id'], authorId: map['messenger']['id'], authorName: map['messenger']['name'], authorAvatarUrl: map['messenger']['avatar']['large'], recipientId: map['recipient']['id'], recipientName: map['recipient']['name'], recipientAvatarUrl: map['recipient']['avatar']['large'], siteUrl: map['siteUrl'], text: parseMarkdown(map['message'] ?? ''), createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']), isOwned: map['messenger']['id'] == viewerId || map['recipient']['id'] == viewerId, isPrivate: map['isPrivate'] ?? false, replyCount: map['replyCount'] ?? 0, likeCount: map['likeCount'] ?? 0, isLiked: map['isLiked'] ?? false, isSubscribed: map['isSubscribed'] ?? false, isPinned: false, ); case 'ANIME_LIST': case 'MANGA_LIST': if (map['user'] == null || map['media'] == null) return null; final progress = map['progress'] != null ? '${map['progress']} of ' : ''; final status = (map['status'] as String)[0].toUpperCase() + (map['status'] as String).substring(1); return MediaActivity( id: map['id'], authorId: map['user']['id'], authorName: map['user']['name'], authorAvatarUrl: map['user']['avatar']['large'], mediaId: map['media']['id'], title: map['media']['title']['userPreferred'], coverUrl: map['media']['coverImage'][imageQuality.value], format: StringExtension.tryNoScreamingSnakeCase(map['media']['format']), isAnime: map['type'] == 'ANIME_LIST', siteUrl: map['siteUrl'], text: '$status $progress', createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']), isOwned: map['user']['id'] == viewerId, replyCount: map['replyCount'] ?? 0, likeCount: map['likeCount'] ?? 0, isLiked: map['isLiked'] ?? false, isSubscribed: map['isSubscribed'] ?? false, isPinned: map['isPinned'] ?? false, ); default: return null; } } catch (_) { return null; } } final int id; final int authorId; final String authorName; final String authorAvatarUrl; final String text; final String siteUrl; final DateTime createdAt; final bool isOwned; int replyCount; int likeCount; bool isLiked; bool isSubscribed; bool isPinned; } class StatusActivity extends Activity { StatusActivity({ required super.id, required super.authorId, required super.authorName, required super.authorAvatarUrl, required super.createdAt, required super.text, required super.siteUrl, required super.isOwned, required super.replyCount, required super.likeCount, required super.isLiked, required super.isSubscribed, required super.isPinned, }); } class MessageActivity extends Activity { MessageActivity({ required super.id, required super.authorId, required super.authorName, required super.authorAvatarUrl, required super.createdAt, required super.text, required super.siteUrl, required super.isOwned, required super.replyCount, required super.likeCount, required super.isLiked, required super.isSubscribed, required super.isPinned, required this.recipientId, required this.recipientName, required this.recipientAvatarUrl, required this.isPrivate, }); final int recipientId; final String recipientName; final String recipientAvatarUrl; final bool isPrivate; } class MediaActivity extends Activity { MediaActivity({ required super.id, required super.authorId, required super.authorName, required super.authorAvatarUrl, required super.createdAt, required super.text, required super.siteUrl, required super.isOwned, required super.replyCount, required super.likeCount, required super.isLiked, required super.isSubscribed, required super.isPinned, required this.mediaId, required this.title, required this.coverUrl, required this.isAnime, required this.format, }); final int mediaId; final String title; final String coverUrl; final bool isAnime; final String? format; } class ActivityReply { ActivityReply._({ required this.id, required this.authorId, required this.authorName, required this.authorAvatarUrl, required this.text, required this.createdAt, this.likeCount = 0, this.isLiked = false, }); static ActivityReply? maybe(Map map) { if (map['id'] == null || map['user']?['id'] == null) return null; return ActivityReply._( id: map['id'], authorId: map['user']['id'], authorName: map['user']['name'], authorAvatarUrl: map['user']['avatar']['large'], text: parseMarkdown(map['text'] ?? ''), createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']), likeCount: map['likeCount'] ?? 0, isLiked: map['isLiked'] ?? false, ); } final int id; final int authorId; final String authorName; final String authorAvatarUrl; final String text; final DateTime createdAt; int likeCount; bool isLiked; } ================================================ FILE: lib/feature/activity/activity_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/feature/activity/activity_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; import 'package:otraku/util/paged.dart'; final activityProvider = AsyncNotifierProvider.autoDispose .family(ActivityNotifier.new); class ActivityNotifier extends AsyncNotifier { ActivityNotifier(this.arg); final int arg; int? _viewerId; @override FutureOr build() async { _viewerId = ref.watch(viewerIdProvider); return await _fetch(null); } Future fetch() async { if (!(state.value?.replies.hasNext ?? true)) return; state = await AsyncValue.guard(() => _fetch(state.value)); } Future _fetch(ExpandedActivity? oldState) async { final replies = oldState?.replies ?? const Paged(); final data = await ref.read(repositoryProvider).request(GqlQuery.activity, { 'id': arg, 'page': replies.next, if (replies.next == 1) 'withActivity': true, }); final items = []; for (final r in data['Page']['activityReplies']) { final item = ActivityReply.maybe(r); if (item != null) items.add(item); } final activity = oldState?.activity ?? Activity.maybe( data['Activity'], _viewerId, ref.read(persistenceProvider).options.imageQuality, ); if (activity == null) throw StateError('Could not parse activity'); return ExpandedActivity( activity, replies.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false), ); } void replace(Activity activity) { final value = state.value; if (value == null) return; state = AsyncValue.data(ExpandedActivity(activity, value.replies)); } void appendReply(Map map) { final value = state.value; if (value == null) return; final reply = ActivityReply.maybe(map); if (reply == null) return; value.activity.replyCount++; state = AsyncValue.data( ExpandedActivity( value.activity, Paged( items: [...value.replies.items, reply], hasNext: value.replies.hasNext, next: value.replies.next, ), ), ); } void replaceReply(Map map) { final value = state.value; if (value == null) return; final reply = ActivityReply.maybe(map); if (reply == null) return; for (int i = 0; i < value.replies.items.length; i++) { if (value.replies.items[i].id == reply.id) { value.replies.items[i] = reply; state = AsyncValue.data( ExpandedActivity( value.activity, Paged( items: value.replies.items, hasNext: value.replies.hasNext, next: value.replies.next, ), ), ); return; } } } Future toggleLike() { return ref.read(repositoryProvider).request(GqlMutation.toggleLike, { 'id': arg, 'type': 'ACTIVITY', }).getErrorOrNull(); } Future toggleSubscription() { final isSubscribed = state.value?.activity.isSubscribed; if (isSubscribed == null) return Future.value(); return ref.read(repositoryProvider).request(GqlMutation.toggleActivitySubscription, { 'id': arg, 'subscribe': isSubscribed, }).getErrorOrNull(); } Future togglePin() { final isPinned = state.value?.activity.isPinned; if (isPinned == null) return Future.value(); return ref.read(repositoryProvider).request(GqlMutation.toggleActivityPin, { 'id': arg, 'pinned': isPinned, }).getErrorOrNull(); } Future toggleReplyLike(int replyId) { return ref.read(repositoryProvider).request(GqlMutation.toggleLike, { 'id': replyId, 'type': 'ACTIVITY_REPLY', }).getErrorOrNull(); } Future remove() { return ref.read(repositoryProvider).request(GqlMutation.deleteActivity, { 'id': arg, }).getErrorOrNull(); } Future removeReply(int replyId) async { final value = state.value; if (value == null) return Future.value(); final err = await ref.read(repositoryProvider).request(GqlMutation.deleteActivityReply, { 'id': replyId, }).getErrorOrNull(); if (err != null) return err; for (int i = 0; i < value.replies.items.length; i++) { if (value.replies.items[i].id == replyId) { value.replies.items.removeAt(i); value.activity.replyCount--; state = AsyncValue.data( ExpandedActivity( value.activity, Paged( items: value.replies.items, hasNext: value.replies.hasNext, next: value.replies.next, ), ), ); break; } } return null; } } ================================================ FILE: lib/feature/activity/activity_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/feature/activity/activities_provider.dart'; import 'package:otraku/feature/activity/activity_model.dart'; import 'package:otraku/feature/activity/activity_provider.dart'; import 'package:otraku/feature/activity/activity_card.dart'; import 'package:otraku/feature/activity/reply_card.dart'; import 'package:otraku/feature/composition/composition_model.dart'; import 'package:otraku/feature/composition/composition_view.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/sheets.dart'; class ActivityView extends ConsumerStatefulWidget { const ActivityView(this.id, this.sourceTag); final int id; final ActivitiesTag? sourceTag; @override ConsumerState createState() => _ActivityViewState(); } class _ActivityViewState extends ConsumerState { late final _scrollCtrl = PagedController( loadMore: () => ref.read(activityProvider(widget.id).notifier).fetch(), ); @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final activity = ref.watch(activityProvider(widget.id).select((s) => s.value?.activity)); return AdaptiveScaffold( topBar: TopBar(trailing: [if (activity != null) _TopBarContent(activity)]), floatingAction: HidingFloatingActionButton( key: const Key('Reply'), scrollCtrl: _scrollCtrl, child: FloatingActionButton( tooltip: 'New Reply', child: const Icon(Icons.edit_outlined), onPressed: () => showSheet( context, CompositionView( tag: ActivityReplyCompositionTag(id: null, activityId: widget.id), onSaved: (map) => ref.read(activityProvider(widget.id).notifier).appendReply(map), ), ), ), ), child: _View(id: widget.id, sourceTag: widget.sourceTag, scrollCtrl: _scrollCtrl), ); } } class _TopBarContent extends StatelessWidget { const _TopBarContent(this.activity); final Activity activity; @override Widget build(BuildContext context) { return Expanded( child: Row( children: [ Flexible( child: GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.user(activity.authorId, activity.authorAvatarUrl)), child: Row( mainAxisSize: .min, children: [ Hero( tag: activity.authorId, child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage(activity.authorAvatarUrl, height: 40, width: 40), ), ), const SizedBox(width: Theming.offset), Flexible(child: Text(activity.authorName, overflow: .ellipsis, maxLines: 1)), ], ), ), ), ...switch (activity) { MessageActivity message => [ if (message.isPrivate) const Padding( padding: .only(left: Theming.offset), child: Icon(Ionicons.eye_off_outline), ), const Padding( padding: .symmetric(horizontal: Theming.offset), child: Icon(Icons.arrow_right_alt), ), GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.user(message.recipientId, message.recipientAvatarUrl)), child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage(message.recipientAvatarUrl, height: 40, width: 40), ), ), ], _ when activity.isPinned => const [ Padding( padding: .only(left: Theming.offset), child: Icon(Icons.push_pin_outlined), ), ], _ => const [], }, ], ), ); } } class _View extends ConsumerWidget { const _View({required this.id, required this.sourceTag, required this.scrollCtrl}); final int id; final ActivitiesTag? sourceTag; final PagedController scrollCtrl; @override Widget build(BuildContext context, WidgetRef ref) { ref.listen( activityProvider(id), (_, s) => s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())), ); final viewerId = ref.watch(viewerIdProvider); final options = ref.watch(persistenceProvider.select((s) => s.options)); return ref .watch(activityProvider(id)) .unwrapPrevious() .when( loading: () => const Center(child: Loader()), error: (_, _) => const Center(child: Text('Failed to load activity')), data: (data) { return ConstrainedView( child: CustomScrollView( physics: Theming.bouncyPhysics, controller: scrollCtrl, slivers: [ SliverRefreshControl(onRefresh: () => ref.invalidate(activityProvider(id))), SliverToBoxAdapter( child: ActivityCard( withHeader: false, analogClock: options.analogClock, highContrast: options.highContrast, activity: data.activity, footer: ActivityFooter( viewerId: viewerId, activity: data.activity, toggleLike: () => _toggleLike(ref, data.activity), toggleSubscription: () => _toggleSubscription(ref, data.activity), togglePin: () => _togglePin(ref, data.activity), remove: () => _remove(context, ref, data.activity), onEdited: (map) => _onEdited(ref, map), reply: () => _reply(context, ref, data.activity), ), ), ), SliverList( delegate: SliverChildBuilderDelegate( childCount: data.replies.items.length, (context, i) => ReplyCard( activityId: id, analogClock: options.analogClock, highContrast: options.highContrast, reply: data.replies.items[i], toggleLike: () => ref .read(activityProvider(id).notifier) .toggleReplyLike(data.replies.items[i].id), ), ), ), SliverFooter(loading: data.replies.hasNext), ], ), ); }, ); } Future _toggleLike(WidgetRef ref, Activity activity) { if (sourceTag != null) { return ref.read(activitiesProvider(sourceTag!).notifier).toggleLike(activity); } return ref.read(activityProvider(id).notifier).toggleLike(); } Future _toggleSubscription(WidgetRef ref, Activity activity) { if (sourceTag != null) { return ref.read(activitiesProvider(sourceTag!).notifier).toggleSubscription(activity); } return ref.read(activityProvider(id).notifier).toggleSubscription(); } Future _togglePin(WidgetRef ref, Activity activity) { if (sourceTag != null) { return ref.read(activitiesProvider(sourceTag!).notifier).togglePin(activity); } return ref.read(activityProvider(id).notifier).togglePin(); } Future _remove(BuildContext context, WidgetRef ref, Activity activity) { Navigator.pop(context); if (sourceTag != null) { return ref.read(activitiesProvider(sourceTag!).notifier).remove(activity); } return ref.read(activityProvider(id).notifier).remove(); } void _onEdited(WidgetRef ref, Map map) { final persistence = ref.read(persistenceProvider); final activity = Activity.maybe( map, persistence.accountGroup.account?.id, persistence.options.imageQuality, ); if (activity == null) return; ref.read(activityProvider(id).notifier).replace(activity); if (sourceTag != null) { ref.read(activitiesProvider(sourceTag!).notifier).replace(activity); } } Future _reply(BuildContext context, WidgetRef ref, Activity activity) { return showSheet( context, CompositionView( defaultText: '@${activity.authorName} ', tag: ActivityReplyCompositionTag(id: null, activityId: id), onSaved: (map) => ref.read(activityProvider(id).notifier).appendReply(map), ), ); } } ================================================ FILE: lib/feature/activity/reply_card.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/activity/activity_model.dart'; import 'package:otraku/feature/activity/activity_provider.dart'; import 'package:otraku/feature/composition/composition_model.dart'; import 'package:otraku/feature/composition/composition_view.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/widget/timestamp.dart'; class ReplyCard extends StatelessWidget { const ReplyCard({ required this.activityId, required this.reply, required this.analogClock, required this.highContrast, required this.toggleLike, }); final int activityId; final ActivityReply reply; final bool analogClock; final bool highContrast; final Future Function() toggleLike; @override Widget build(BuildContext context) { const avatarSize = 50.0; return Column( mainAxisSize: .min, crossAxisAlignment: .start, spacing: 5, children: [ GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.user(reply.authorId, reply.authorAvatarUrl)), child: Row( mainAxisSize: .min, spacing: Theming.offset, children: [ ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage(reply.authorAvatarUrl, height: avatarSize, width: avatarSize), ), Flexible(child: Text(reply.authorName, overflow: .ellipsis, maxLines: 1)), ], ), ), CardExtension.highContrast(highContrast)( margin: const .only(bottom: Theming.offset), child: Padding( padding: const .only(top: Theming.offset, left: Theming.offset, right: Theming.offset), child: Column( mainAxisSize: .min, children: [ UnconstrainedBox( constrainedAxis: Axis.horizontal, alignment: Alignment.topLeft, child: HtmlContent(reply.text), ), Row( mainAxisAlignment: .spaceBetween, spacing: 5, children: [ Expanded(child: Timestamp(reply.createdAt, analogClock)), Consumer( builder: (context, ref, _) => SizedBox( height: 40, child: reply.authorId == ref.watch(viewerIdProvider) ? Tooltip( message: 'More', child: InkResponse( radius: Theming.radiusSmall.x, onTap: () => _showMoreSheet(context, ref), child: const Icon( Ionicons.ellipsis_horizontal, size: Theming.iconSmall, ), ), ) : _ReplyMentionButton(ref, activityId, reply.authorName), ), ), _ReplyLikeButton(reply: reply, toggleLike: toggleLike), ], ), ], ), ), ), ], ); } /// Show a sheet with additional options. void _showMoreSheet(BuildContext context, WidgetRef ref) { showSheet( context, SimpleSheet.list([ ListTile( title: const Text('Edit'), leading: const Icon(Icons.edit_outlined), onTap: () => showSheet( context, CompositionView( tag: ActivityReplyCompositionTag(id: reply.id, activityId: activityId), onSaved: (map) { ref.read(activityProvider(activityId).notifier).replaceReply(map); Navigator.pop(context); }, ), ), ), ListTile( title: const Text('Delete'), leading: const Icon(Ionicons.trash_outline), onTap: () => ConfirmationDialog.show( context, title: 'Delete?', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: () async { final err = await ref .read(activityProvider(activityId).notifier) .removeReply(reply.id); if (err == null) { if (context.mounted) Navigator.pop(context); return; } if (context.mounted) { SnackBarExtension.show(context, err.toString()); Navigator.pop(context); } }, ), ), ]), ); } } class _ReplyMentionButton extends StatelessWidget { const _ReplyMentionButton(this.ref, this.activityId, this.username); final WidgetRef ref; final int activityId; final String username; @override Widget build(BuildContext context) { return SizedBox( height: 40, child: Tooltip( message: 'Reply', child: InkResponse( radius: Theming.radiusSmall.x, onTap: () => showSheet( context, CompositionView( defaultText: '@$username ', tag: ActivityReplyCompositionTag(id: null, activityId: activityId), onSaved: (map) => ref.read(activityProvider(activityId).notifier).appendReply(map), ), ), child: const Icon(Icons.reply_rounded, size: Theming.iconSmall), ), ), ); } } class _ReplyLikeButton extends StatefulWidget { const _ReplyLikeButton({required this.reply, required this.toggleLike}); final ActivityReply reply; final Future Function() toggleLike; @override _ReplyLikeButtonState createState() => _ReplyLikeButtonState(); } class _ReplyLikeButtonState extends State<_ReplyLikeButton> { @override Widget build(BuildContext context) { return SizedBox( height: 40, child: Tooltip( message: !widget.reply.isLiked ? 'Like' : 'Unlike', child: InkResponse( radius: Theming.radiusSmall.x, onTap: _toggleLike, child: Row( children: [ Text( widget.reply.likeCount.toString(), style: !widget.reply.isLiked ? TextTheme.of(context).labelSmall : TextTheme.of( context, ).labelSmall!.copyWith(color: ColorScheme.of(context).primary), ), const SizedBox(width: 5), Icon( !widget.reply.isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded, size: Theming.iconSmall, color: widget.reply.isLiked ? ColorScheme.of(context).primary : null, ), ], ), ), ), ); } void _toggleLike() async { final reply = widget.reply; final isLiked = reply.isLiked; setState(() { reply.isLiked = !isLiked; reply.likeCount += isLiked ? -1 : 1; }); final err = await widget.toggleLike(); if (err == null) return; setState(() { reply.isLiked = isLiked; reply.likeCount += isLiked ? 1 : -1; }); if (mounted) SnackBarExtension.show(context, err.toString()); } } ================================================ FILE: lib/feature/calendar/calendar_filter_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/calendar/calendar_models.dart'; final calendarFilterProvider = NotifierProvider.autoDispose( CalendarFilterNotifier.new, ); class CalendarFilterNotifier extends Notifier { @override CalendarFilter build() => ref.watch(persistenceProvider.select((s) => s.calendarFilter)); @override set state(CalendarFilter newState) { ref.read(persistenceProvider.notifier).setCalendarFilter(newState); } } ================================================ FILE: lib/feature/calendar/calendar_filter_sheet.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/feature/calendar/calendar_filter_provider.dart'; import 'package:otraku/feature/calendar/calendar_models.dart'; import 'package:otraku/widget/input/chip_selector.dart'; void showCalendarFilterSheet(BuildContext context, WidgetRef ref) { final highContrast = ref.read(persistenceProvider.select((s) => s.options.highContrast)); final filter = ref.read(calendarFilterProvider); CalendarSeasonFilter season = filter.season; CalendarStatusFilter status = filter.status; showSheet( context, SimpleSheet( initialHeight: Theming.normalTapTarget * 2 + MediaQuery.paddingOf(context).bottom + 40, builder: (context, scrollCtrl) => ListView( controller: scrollCtrl, physics: Theming.bouncyPhysics, padding: const .symmetric(horizontal: Theming.offset, vertical: 20), children: [ ChipSelector( title: 'Season', items: CalendarSeasonFilter.values.skip(1).map((v) => (v.label, v)).toList(), value: season != .all ? season : null, onChanged: (v) => season = v ?? .all, highContrast: highContrast, ), ChipSelector( title: 'Status', items: CalendarStatusFilter.values.skip(1).map((v) => (v.label, v)).toList(), value: status != .all ? status : null, onChanged: (v) => status = v ?? .all, highContrast: highContrast, ), ], ), ), ).then((_) { if (season != filter.season || status != filter.status) { ref.read(calendarFilterProvider.notifier).state = filter.copyWith( season: season, status: status, ); } }); } ================================================ FILE: lib/feature/calendar/calendar_models.dart ================================================ import 'package:flutter/widgets.dart'; import 'package:otraku/extension/color_extension.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/extension/enum_extension.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/feature/collection/collection_models.dart'; class CalendarItem { const CalendarItem._({ required this.mediaId, required this.title, required this.cover, required this.episode, required this.airingAt, required this.entryStatus, required this.streamingServices, }); factory CalendarItem(Map map, ImageQuality imageQuality) { final streamingServices = []; if (map['media']['externalLinks'] != null) { for (final link in map['media']['externalLinks']) { if (link['type'] == 'STREAMING') { streamingServices.add(( url: link['url'], site: link['site'], color: link['color'] != null ? ColorExtension.fromHexString(link['color']) : null, )); } } } return CalendarItem._( mediaId: map['mediaId'], title: map['media']['title']['userPreferred'], cover: map['media']['coverImage'][imageQuality.value], episode: map['episode'], airingAt: DateTimeExtension.fromSecondsSinceEpoch(map['airingAt']), entryStatus: ListStatus.from(map['media']['mediaListEntry']?['status']), streamingServices: streamingServices, ); } final int mediaId; final String title; final String cover; final int episode; final DateTime airingAt; final ListStatus? entryStatus; final List streamingServices; } typedef StreamingService = ({String url, String site, Color? color}); class CalendarFilter { const CalendarFilter({required this.date, required this.season, required this.status}); factory CalendarFilter.empty() => CalendarFilter(date: DateTime.now(), season: .all, status: .all); factory CalendarFilter.fromPersistenceMap(Map map) { final season = CalendarSeasonFilter.values.getOrFirst(map['season']); final status = CalendarStatusFilter.values.getOrFirst(map['status']); return CalendarFilter(date: DateTime.now(), season: season, status: status); } final DateTime date; final CalendarSeasonFilter season; final CalendarStatusFilter status; CalendarFilter copyWith({ DateTime? date, CalendarSeasonFilter? season, CalendarStatusFilter? status, }) => CalendarFilter( date: date ?? this.date, season: season ?? this.season, status: status ?? this.status, ); Map toPersistenceMap() => {'season': season.index, 'status': status.index}; } enum CalendarSeasonFilter { all('All'), current('Current'), previous('Previous'), other('Other'); const CalendarSeasonFilter(this.label); final String label; } enum CalendarStatusFilter { all('All'), watchingAndPlanning('Watching And Planning'), notInLists('Not In Lists'), other('Other'); const CalendarStatusFilter(this.label); final String label; } ================================================ FILE: lib/feature/calendar/calendar_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/util/graphql.dart'; import 'package:otraku/feature/calendar/calendar_filter_provider.dart'; import 'package:otraku/feature/calendar/calendar_models.dart'; import 'package:otraku/feature/collection/collection_models.dart'; final calendarProvider = AsyncNotifierProvider.autoDispose>( CalendarNotifier.new, ); class CalendarNotifier extends AsyncNotifier> { late CalendarFilter filter; @override FutureOr> build() async { filter = ref.watch(calendarFilterProvider); return await _fetch(const Paged()); } Future fetch(bool onAnime) async { final oldState = state.value ?? const Paged(); if (!oldState.hasNext) return; state = await AsyncValue.guard(() => _fetch(oldState)); } Future> _fetch(Paged oldState) async { final airingFrom = filter.date.copyWith(hour: 0, minute: 0, second: 0).secondsSinceEpoch; final airingTo = filter.date.copyWith(hour: 23, minute: 59, second: 59).secondsSinceEpoch; final data = await ref.read(repositoryProvider).request(GqlQuery.calendar, { 'page': oldState.next, 'airingFrom': airingFrom, 'airingTo': airingTo, }); final imageQuality = ref.read(persistenceProvider).options.imageQuality; final items = []; for (final c in data['Page']['airingSchedules']) { final season = c['media']['season']; final year = c['media']['seasonYear']; if (season == null || year == null) continue; switch (filter.season) { case .current: final currSeason = _previousAndCurrentSeason().$2; if (season != currSeason || year < filter.date.year - 1) continue; case .previous: final prevSeason = _previousAndCurrentSeason().$1; if (season != prevSeason || year < filter.date.year - 1) continue; case .other: final (prevSeason, currSeason) = _previousAndCurrentSeason(); if ((season == prevSeason || season == currSeason) && year >= filter.date.year - 1) { continue; } break; case .all: break; } final status = c['media']['mediaListEntry']?['status']; switch (filter.status) { case .notInLists: if (status != null) continue; case .watchingAndPlanning: if (status != ListStatus.current.value && status != ListStatus.planning.value) { continue; } case .other: if (status == null || status == ListStatus.current.value || status == ListStatus.planning.value) { continue; } case .all: break; } items.add(CalendarItem(c, imageQuality)); } return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false); } (String, String) _previousAndCurrentSeason() => switch (filter.date.month) { >= 3 && <= 5 => ('WINTER', 'SPRING'), >= 6 && <= 8 => ('SPRING', 'SUMMER'), >= 9 && <= 11 => ('SUMMER', 'FALL'), _ => ('FALL', 'WINTER'), }; } ================================================ FILE: lib/feature/calendar/calendar_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/navigation_tool.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/widget/text_rail.dart'; import 'package:otraku/feature/calendar/calendar_filter_provider.dart'; import 'package:otraku/feature/calendar/calendar_filter_sheet.dart'; import 'package:otraku/feature/calendar/calendar_models.dart'; import 'package:otraku/feature/calendar/calendar_provider.dart'; class CalendarView extends StatefulWidget { const CalendarView(); @override State createState() => _CalendarViewState(); } class _CalendarViewState extends State { final _scrollCtrl = ScrollController(); @override void dispose() { super.dispose(); _scrollCtrl.dispose(); } @override Widget build(BuildContext context) { final textTheme = TextTheme.of(context); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!); final tileHeight = bodyMediumLineHeight * 2 + labelMediumLineHeight + 55; final coverWidth = tileHeight / Theming.coverHtoWRatio; return Consumer( builder: (context, ref, _) { final options = ref.watch(persistenceProvider.select((s) => s.options)); final date = ref.watch(calendarFilterProvider.select((s) => s.date)); final today = DateTime.now(); final isBeforeToday = date.day < today.day && date.month == today.month && date.year == today.year; return AdaptiveScaffold( topBar: const TopBar(title: 'Calendar'), floatingAction: HidingFloatingActionButton( key: const Key('filter'), scrollCtrl: _scrollCtrl, child: FloatingActionButton( tooltip: 'Filter', onPressed: () => showCalendarFilterSheet(context, ref), child: const Icon(Ionicons.funnel_outline), ), ), bottomBar: BottomBar([ const SizedBox(width: Theming.offset), SizedBox( width: 60, child: isBeforeToday ? null : IconButton( icon: const Icon(Icons.arrow_back_ios_rounded), onPressed: () => _setDate(ref, date.subtract(const Duration(days: 1))), ), ), Expanded( child: TextButton( onPressed: () => showDatePicker( context: context, initialDate: date, firstDate: today.add(const Duration(days: -1)), lastDate: today.add(const Duration(days: 150)), ).then((newDate) { if (newDate != null && newDate != date) { _setDate(ref, newDate); } }), child: Text(date.formattedWithWeekDay), ), ), SizedBox( width: 60, child: IconButton( icon: const Icon(Icons.arrow_forward_ios_rounded), onPressed: () => _setDate(ref, date.add(const Duration(days: 1))), ), ), const SizedBox(width: Theming.offset), ]), child: PagedView( provider: calendarProvider, scrollCtrl: _scrollCtrl, onRefresh: (invalidate) => invalidate(calendarProvider), onData: (data) => SliverGrid( delegate: SliverChildBuilderDelegate( (context, i) => _Tile(data.items[i], coverWidth, options.highContrast, options.analogClock), childCount: data.items.length, ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 1, mainAxisExtent: tileHeight, mainAxisSpacing: Theming.offset, crossAxisSpacing: Theming.offset, ), ), ), ); }, ); } void _setDate(WidgetRef ref, DateTime date) { final filter = ref.read(calendarFilterProvider); ref.read(calendarFilterProvider.notifier).state = filter.copyWith(date: date); } } class _Tile extends StatelessWidget { const _Tile(this.item, this.coverWidth, this.highContrast, this.analogClock); final CalendarItem item; final double coverWidth; final bool highContrast; final bool analogClock; @override Widget build(BuildContext context) { final textRailItems = { item.airingAt.formattedTime(analogClock): true, if (item.airingAt.isAfter(DateTime.now())) 'Ep ${item.episode} in ${item.airingAt.timeUntil}': false else 'Ep ${item.episode}': false, }; if (item.entryStatus != null) { textRailItems[item.entryStatus!.label(true)] = true; } return CardExtension.highContrast(highContrast)( child: MediaRouteTile( id: item.mediaId, imageUrl: item.cover, child: Row( children: [ Hero( tag: item.mediaId, child: ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: Container( width: coverWidth, color: ColorScheme.of(context).surfaceContainerHighest, child: CachedImage(item.cover), ), ), ), Expanded( child: Padding( padding: const .symmetric(vertical: 5), child: Column( crossAxisAlignment: .start, mainAxisAlignment: .spaceAround, children: [ Flexible( child: Padding( padding: const .symmetric(horizontal: Theming.offset), child: Text(item.title, overflow: .ellipsis, maxLines: 2), ), ), Padding( padding: const .symmetric(horizontal: Theming.offset, vertical: 5), child: TextRail( textRailItems, style: TextTheme.of(context).labelMedium, maxLines: 1, ), ), if (item.streamingServices.isNotEmpty) SizedBox(height: 35, child: _ExternalLinkList(item.streamingServices)), ], ), ), ), ], ), ), ); } } class _ExternalLinkList extends StatelessWidget { const _ExternalLinkList(this.links); final List links; @override Widget build(BuildContext context) { return ListView.builder( scrollDirection: Axis.horizontal, padding: const .only(left: Theming.offset, right: Theming.offset / 2), itemCount: links.length, itemBuilder: (context, i) { return Padding( padding: const .only(right: Theming.offset / 2), child: ActionChip( onPressed: () => SnackBarExtension.launch(context, links[i].url), label: Text(links[i].site), avatar: links[i].color != null ? Container( height: 15, width: 15, decoration: BoxDecoration( borderRadius: Theming.borderRadiusSmall, color: links[i].color, ), ) : null, ), ); }, ); } } ================================================ FILE: lib/feature/character/character_anime_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/character/character_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/grid/dual_relation_grid.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/feature/character/character_provider.dart'; import 'package:otraku/widget/shadowed_overflow_list.dart'; class CharacterAnimeSubview extends StatelessWidget { const CharacterAnimeSubview({ required this.id, required this.scrollCtrl, required this.highContrast, }); final int id; final ScrollController scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { return PagedView<(CharacterRelatedItem, CharacterRelatedItem?)>( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(characterMediaProvider(id)), provider: characterMediaProvider( id, ).select((s) => s.unwrapPrevious().whenData((data) => data.assembleAnimeWithVoiceActors())), onData: (data) { return SliverMainAxisGroup( slivers: [ _LanguageSelected(id), DualRelationGrid( items: data.items, onTapPrimary: (item) => context.push(Routes.media(item.tileId, item.tileImageUrl)), onTapSecondary: (item) => context.push(Routes.staff(item.tileId, item.tileImageUrl)), highContrast: highContrast, ), ], ); }, ); } } class _LanguageSelected extends StatelessWidget { const _LanguageSelected(this.id); final int id; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, child) { final selection = ref.watch( characterMediaProvider(id).select((s) { final value = s.value; if (value == null) return null; return (value.languageToVoiceActors, value.selectedLanguage); }), ); if (selection == null) return const SliverToBoxAdapter(); final languageMappings = selection.$1; final selectedLanguage = selection.$2; if (languageMappings.length < 2) return const SliverToBoxAdapter(); return SliverToBoxAdapter( child: SizedBox( height: Theming.normalTapTarget, child: ShadowedOverflowList( itemCount: languageMappings.length, itemBuilder: (context, i) => FilterChip( label: Text(languageMappings[i].language), selected: i == selectedLanguage, onSelected: (selected) { if (!selected) return; ref.read(characterMediaProvider(id).notifier).changeLanguage(i); }, ), ), ), ); }, ); } } ================================================ FILE: lib/feature/character/character_filter_model.dart ================================================ import 'package:otraku/feature/media/media_models.dart'; class CharacterFilter { const CharacterFilter({this.sort = .trendingDesc, this.inLists}); final MediaSort sort; final bool? inLists; CharacterFilter copyWith({MediaSort? sort, (bool?,)? inLists}) => CharacterFilter( sort: sort ?? this.sort, inLists: inLists == null ? this.inLists : inLists.$1, ); } ================================================ FILE: lib/feature/character/character_filter_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/character/character_filter_model.dart'; final characterFilterProvider = NotifierProvider.autoDispose .family(CharacterFilterNotifier.new); class CharacterFilterNotifier extends Notifier { CharacterFilterNotifier(this.arg); final int arg; @override CharacterFilter build() => const CharacterFilter(); @override set state(CharacterFilter newState) => super.state = newState; } ================================================ FILE: lib/feature/character/character_floating_actions.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/character/character_filter_provider.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/sheets.dart'; class CharacterMediaFilterButton extends StatelessWidget { const CharacterMediaFilterButton(this.id, this.ref); final int id; final WidgetRef ref; @override Widget build(BuildContext context) { return FloatingActionButton( tooltip: 'Filter', child: const Icon(Ionicons.funnel_outline), onPressed: () { var filter = ref.read(characterFilterProvider(id)); final onDone = (_) => ref.read(characterFilterProvider(id).notifier).state = filter; final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast)); showSheet( context, SimpleSheet( initialHeight: Theming.normalTapTarget * 2.5 + MediaQuery.paddingOf(context).bottom + 40, builder: (context, scrollCtrl) => ListView( controller: scrollCtrl, physics: Theming.bouncyPhysics, padding: const .symmetric(horizontal: Theming.offset, vertical: 20), children: [ ChipSelector.ensureSelected( title: 'Sort', items: MediaSort.values.map((v) => (v.label, v)).toList(), value: filter.sort, onChanged: (v) => filter = filter.copyWith(sort: v), highContrast: highContrast, ), ChipSelector( title: 'List Presence', items: const [('In Lists', true), ('Not in Lists', false)], value: filter.inLists, onChanged: (v) => filter = filter.copyWith(inLists: (v,)), highContrast: highContrast, ), ], ), ), ).then(onDone); }, ); } } ================================================ FILE: lib/feature/character/character_header.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/character/character_model.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/content_header.dart'; import 'package:otraku/widget/table_list.dart'; class CharacterHeader extends StatelessWidget { const CharacterHeader.withTabBar({ required this.id, required this.imageUrl, required this.character, required TabController this.tabCtrl, required void Function() this.scrollToTop, required this.toggleFavorite, required this.highContrast, }); const CharacterHeader.withoutTabBar({ required this.id, required this.imageUrl, required this.character, required this.toggleFavorite, required this.highContrast, }) : tabCtrl = null, scrollToTop = null; final int id; final String? imageUrl; final Character? character; final TabController? tabCtrl; final void Function()? scrollToTop; final Future Function() toggleFavorite; final bool highContrast; @override Widget build(BuildContext context) { return ContentHeader( imageUrl: imageUrl ?? character?.imageUrl, imageHeightToWidthRatio: Theming.coverHtoWRatio, imageHeroTag: id, siteUrl: character?.siteUrl, title: character?.preferredName, details: character != null ? [ TableList([ ('Favorites', character!.favorites.toString()), if (character!.gender != null) ('Gender', character!.gender!), ], highContrast: highContrast), ] : const [], tabBarConfig: tabCtrl != null && scrollToTop != null ? (tabCtrl: tabCtrl!, scrollToTop: scrollToTop!, tabs: tabsWithOverview) : null, trailingTopButtons: [if (character != null) _FavoriteButton(character!, toggleFavorite)], ); } static const tabsWithoutOverview = [Tab(text: 'Anime'), Tab(text: 'Manga')]; static const tabsWithOverview = [Tab(text: 'Overview'), ...tabsWithoutOverview]; } class _FavoriteButton extends StatefulWidget { const _FavoriteButton(this.character, this.toggleFavorite); final Character character; final Future Function() toggleFavorite; @override State<_FavoriteButton> createState() => __FavoriteButtonState(); } class __FavoriteButtonState extends State<_FavoriteButton> { @override Widget build(BuildContext context) { final character = widget.character; return IconButton( tooltip: character.isFavorite ? 'Unfavourite' : 'Favourite', icon: character.isFavorite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border), onPressed: () async { setState(() => character.isFavorite = !character.isFavorite); final err = await widget.toggleFavorite(); if (err == null) return; setState(() => character.isFavorite = !character.isFavorite); if (context.mounted) SnackBarExtension.show(context, err.toString()); }, ); } } ================================================ FILE: lib/feature/character/character_item_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/character/character_item_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; class CharacterItemGrid extends StatelessWidget { const CharacterItemGrid(this.items, {required this.highContrast}); final List items; final bool highContrast; @override Widget build(BuildContext context) { final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final textHeight = lineHeight * 2 + 10; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight( minWidth: 100, extraHeight: textHeight, rawHWRatio: Theming.coverHtoWRatio, ), delegate: SliverChildBuilderDelegate( (_, i) => _Tile(items[i], highContrast, textHeight), childCount: items.length, ), ); } } class _Tile extends StatelessWidget { const _Tile(this.item, this.highContrast, this.textHeight); final CharacterItem item; final bool highContrast; final double textHeight; @override Widget build(BuildContext context) { return InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => context.push(Routes.character(item.id, item.imageUrl)), child: CardExtension.highContrast(highContrast)( child: Column( crossAxisAlignment: .stretch, children: [ Expanded( child: Hero( tag: item.id, child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall), child: CachedImage(item.imageUrl), ), ), ), SizedBox( height: textHeight, child: Padding( padding: const .all(5), child: Text(item.name, maxLines: 2, overflow: .ellipsis), ), ), ], ), ), ); } } ================================================ FILE: lib/feature/character/character_item_model.dart ================================================ class CharacterItem { const CharacterItem._({required this.id, required this.name, required this.imageUrl}); factory CharacterItem(Map map) => CharacterItem._( id: map['id'], name: map['name']['userPreferred'], imageUrl: map['image']['large'], ); final int id; final String name; final String imageUrl; } ================================================ FILE: lib/feature/character/character_manga_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/character/character_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/widget/grid/mono_relation_grid.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/feature/character/character_provider.dart'; class CharacterMangaSubview extends StatelessWidget { const CharacterMangaSubview({ required this.id, required this.scrollCtrl, required this.highContrast, }); final int id; final ScrollController scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { return PagedView( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(characterMediaProvider(id)), provider: characterMediaProvider( id, ).select((s) => s.unwrapPrevious().whenData((data) => data.manga)), onData: (data) => MonoRelationGrid( items: data.items, onTap: (item) => context.push(Routes.media(item.tileId, item.tileImageUrl)), highContrast: highContrast, ), ); } } ================================================ FILE: lib/feature/character/character_model.dart ================================================ import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/util/markdown.dart'; import 'package:otraku/feature/settings/settings_model.dart'; import 'package:otraku/util/tile_modelable.dart'; class Character { Character._({ required this.id, required this.preferredName, required this.fullName, required this.nativeName, required this.altNames, required this.altNamesSpoilers, required this.imageUrl, required this.description, required this.dateOfBirth, required this.bloodType, required this.gender, required this.age, required this.siteUrl, required this.favorites, required this.isFavorite, }); factory Character(Map map, PersonNaming personNaming) { final names = map['name']; final nameSegments = [ names['first'], if (names['middle']?.isNotEmpty ?? false) names['middle'], if (names['last']?.isNotEmpty ?? false) names['last'], ]; final fullName = personNaming == .romajiWestern ? nameSegments.join(' ') : nameSegments.reversed.toList().join(' '); final nativeName = names['native']; final altNames = List.from(names['alternative'] ?? []); final altNamesSpoilers = List.from(names['alternativeSpoiler'] ?? [], growable: false); final preferredName = nativeName != null ? personNaming != .native ? fullName : nativeName : fullName; return Character._( id: map['id'], preferredName: preferredName, fullName: fullName, nativeName: nativeName, altNames: altNames, altNamesSpoilers: altNamesSpoilers, description: parseMarkdown(map['description'] ?? ''), imageUrl: map['image']['large'], dateOfBirth: StringExtension.fromFuzzyDate(map['dateOfBirth']), bloodType: map['bloodType'], gender: map['gender'], age: map['age'], siteUrl: map['siteUrl'], favorites: map['favourites'] ?? 0, isFavorite: map['isFavourite'] ?? false, ); } final int id; final String preferredName; final String fullName; final String? nativeName; final List altNames; final List altNamesSpoilers; final String imageUrl; final String description; final String? dateOfBirth; final String? bloodType; final String? gender; final String? age; final String? siteUrl; final int favorites; bool isFavorite; } class CharacterMedia { const CharacterMedia({ this.anime = const Paged(), this.manga = const Paged(), this.languageToVoiceActors = const [], this.selectedLanguage = 0, }); final Paged anime; final Paged manga; /// For each language, a list of voice actors /// is mapped to the corresponding media's id. final List languageToVoiceActors; final int selectedLanguage; /// Returns the media, in which the character has participated, /// along with the voice actors, corresponding to the current [language]. /// If there are multiple actors, the given media is repeated for each actor. Paged<(CharacterRelatedItem, CharacterRelatedItem?)> assembleAnimeWithVoiceActors() { if (languageToVoiceActors.isEmpty) { return Paged( items: anime.items.map((a) => (a, null)).toList(), hasNext: anime.hasNext, next: anime.next, ); } final actorsPerMedia = languageToVoiceActors[selectedLanguage]; final animeAndVoiceActors = <(CharacterRelatedItem, CharacterRelatedItem?)>[]; for (final a in anime.items) { final actors = actorsPerMedia.voiceActors[a.id]; if (actors == null || actors.isEmpty) { animeAndVoiceActors.add((a, null)); continue; } for (final va in actors) { animeAndVoiceActors.add((a, va)); } } return Paged(items: animeAndVoiceActors, hasNext: anime.hasNext, next: anime.next); } } class CharacterRelatedItem implements TileModelable { const CharacterRelatedItem._({ required this.id, required this.name, required this.imageUrl, required this.role, }); factory CharacterRelatedItem.media( Map map, String? role, ImageQuality imageQuality, ) => CharacterRelatedItem._( id: map['id'], name: map['title']['userPreferred'], imageUrl: map['coverImage'][imageQuality.value], role: role, ); factory CharacterRelatedItem.staff(Map map, String? role) => CharacterRelatedItem._( id: map['id'], name: map['name']['userPreferred'], imageUrl: map['image']['large'], role: role, ); final int id; final String name; final String imageUrl; final String? role; @override int get tileId => id; @override String get tileTitle => name; @override String? get tileSubtitle => role; @override String get tileImageUrl => imageUrl; } typedef CharacterLanguageMapping = ({ String language, Map> voiceActors, }); ================================================ FILE: lib/feature/character/character_overview_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/character/character_model.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/table_list.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:otraku/widget/loaders.dart'; class CharacterOverviewSubview extends StatelessWidget { const CharacterOverviewSubview.asFragment({ required this.character, required this.invalidate, required this.highContrast, required ScrollController this.scrollCtrl, }) : header = null; const CharacterOverviewSubview.withHeader({ required this.character, required this.invalidate, required this.highContrast, required Widget this.header, }) : scrollCtrl = null; final Character character; final void Function() invalidate; final Widget? header; final ScrollController? scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); final refreshControl = SliverRefreshControl(onRefresh: invalidate); return CustomScrollView( physics: Theming.bouncyPhysics, controller: scrollCtrl, slivers: [ if (header != null) ...[ header!, MediaQuery( data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)), child: refreshControl, ), ] else refreshControl, SliverPadding( padding: const .symmetric(horizontal: Theming.offset), sliver: SliverMainAxisGroup( slivers: [ _NameTable(character, highContrast), const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)), SliverTableList([ if (character.dateOfBirth != null) ('Birth', character.dateOfBirth!), if (character.age != null) ('Age', character.age!), if (character.bloodType != null) ('Blood Type', character.bloodType!), ], highContrast: highContrast), if (character.description.isNotEmpty) ...[ const SliverToBoxAdapter(child: SizedBox(height: 15)), HtmlContent(character.description, renderMode: RenderMode.sliverList), ], ], ), ), const SliverFooter(), ], ); } } class _NameTable extends StatefulWidget { const _NameTable(this.character, this.highContrast); final Character character; final bool highContrast; @override State<_NameTable> createState() => __NameTableState(); } class __NameTableState extends State<_NameTable> { var _showSpoilers = false; @override Widget build(BuildContext context) { return SliverMainAxisGroup( slivers: [ SliverTableList([ ('Full', widget.character.fullName), if (widget.character.nativeName != null) ('Native', widget.character.nativeName!), ...widget.character.altNames.map((s) => ('Alternative', s)), if (_showSpoilers) ...widget.character.altNamesSpoilers.map((s) => ('Alternative Spoiler', s)), ], highContrast: widget.highContrast), if (widget.character.altNamesSpoilers.isNotEmpty && !_showSpoilers) SliverToBoxAdapter( child: TextButton.icon( label: const Text('Show Spoilers'), icon: const Icon(Ionicons.eye_outline), onPressed: () => setState(() => _showSpoilers = true), ), ), ], ); } } ================================================ FILE: lib/feature/character/character_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/extension/iterable_extension.dart'; import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/feature/character/character_filter_model.dart'; import 'package:otraku/feature/character/character_filter_provider.dart'; import 'package:otraku/feature/character/character_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; import 'package:otraku/feature/settings/settings_provider.dart'; final characterProvider = AsyncNotifierProvider.autoDispose .family(CharacterNotifier.new); final characterMediaProvider = AsyncNotifierProvider.autoDispose .family(CharacterMediaNotifier.new); class CharacterNotifier extends AsyncNotifier { CharacterNotifier(this.arg); final int arg; @override FutureOr build() async { final data = await ref.read(repositoryProvider).request(GqlQuery.character, { 'id': arg, 'withInfo': true, }); final personNaming = await ref.watch(settingsProvider.selectAsync((data) => data.personNaming)); return Character(data['Character'], personNaming); } Future toggleFavorite() { return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, { 'character': arg, }).getErrorOrNull(); } } class CharacterMediaNotifier extends AsyncNotifier { CharacterMediaNotifier(this.arg); final int arg; late CharacterFilter filter; @override FutureOr build() async { filter = ref.watch(characterFilterProvider(arg)); return await _fetch(const CharacterMedia(), null); } Future fetch(bool onAnime) async { final oldState = state.value ?? const CharacterMedia(); if (onAnime) { if (!oldState.anime.hasNext) return; } else { if (!oldState.manga.hasNext) return; } state = await AsyncValue.guard(() => _fetch(oldState, onAnime)); } Future _fetch(CharacterMedia oldState, bool? onAnime) async { final variables = {'id': arg, 'onList': filter.inLists, 'sort': filter.sort.value}; if (onAnime == null) { variables['withAnime'] = true; variables['withManga'] = true; } else if (onAnime) { variables['withAnime'] = true; variables['page'] = oldState.anime.next; } else if (!onAnime) { variables['withManga'] = true; variables['page'] = oldState.manga.next; } var data = await ref.read(repositoryProvider).request(GqlQuery.character, variables); data = data['Character']; final imageQuality = ref.read(persistenceProvider).options.imageQuality; var anime = oldState.anime; var manga = oldState.manga; var languageToVoiceActors = [...oldState.languageToVoiceActors]; var selectedLanguage = oldState.selectedLanguage; if (onAnime == null || onAnime) { final map = data['anime']; final items = []; for (final a in map['edges']) { items.add( CharacterRelatedItem.media( a['node'], StringExtension.tryNoScreamingSnakeCase(a['characterRole']), imageQuality, ), ); if (a['voiceActors'] != null) { for (final va in a['voiceActors']) { final l = StringExtension.tryNoScreamingSnakeCase(va['languageV2']); if (l == null) continue; var languageMapping = languageToVoiceActors.firstWhereOrNull((lm) => lm.language == l); if (languageMapping == null) { languageMapping = (language: l, voiceActors: {}); languageToVoiceActors.add(languageMapping); } final mediaVoiceActors = languageMapping.voiceActors.putIfAbsent( items.last.id, () => [], ); mediaVoiceActors.add(CharacterRelatedItem.staff(va, l)); } } languageToVoiceActors.sort((a, b) { if (a.language == 'Japanese') return -1; if (b.language == 'Japanese') return 1; return a.language.compareTo(b.language); }); } anime = anime.withNext(items, map['pageInfo']['hasNextPage'] ?? false); } if (onAnime == null || !onAnime) { final map = data['manga']; final items = []; for (final m in map['edges']) { items.add( CharacterRelatedItem.media( m['node'], StringExtension.tryNoScreamingSnakeCase(m['characterRole']), imageQuality, ), ); } manga = manga.withNext(items, map['pageInfo']['hasNextPage'] ?? false); } return CharacterMedia( anime: anime, manga: manga, languageToVoiceActors: languageToVoiceActors, selectedLanguage: selectedLanguage, ); } void changeLanguage(int selectedLanguage) => state.whenData((data) { if (selectedLanguage >= data.languageToVoiceActors.length) return; state = AsyncValue.data( CharacterMedia( anime: data.anime, manga: data.manga, languageToVoiceActors: data.languageToVoiceActors, selectedLanguage: selectedLanguage, ), ); }); } ================================================ FILE: lib/feature/character/character_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/scroll_controller_extension.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/character/character_header.dart'; import 'package:otraku/feature/character/character_model.dart'; import 'package:otraku/feature/character/character_floating_actions.dart'; import 'package:otraku/feature/character/character_anime_view.dart'; import 'package:otraku/feature/character/character_manga_view.dart'; import 'package:otraku/feature/character/character_provider.dart'; import 'package:otraku/feature/character/character_overview_view.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/dual_pane_with_tab_bar.dart'; import 'package:otraku/widget/loaders.dart'; class CharacterView extends ConsumerStatefulWidget { const CharacterView(this.id, this.imageUrl); final int id; final String? imageUrl; @override ConsumerState createState() => _CharacterViewState(); } class _CharacterViewState extends ConsumerState { final _scrollCtrl = PagedController(loadMore: () {}); @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { ref.listen(characterProvider(widget.id), (_, s) { if (s.hasError) { SnackBarExtension.show(context, 'Failed to load character: ${s.error}'); } }); final character = ref.watch(characterProvider(widget.id)); final options = ref.watch(persistenceProvider.select((s) => s.options)); final toggleFavorite = () => ref.read(characterProvider(widget.id).notifier).toggleFavorite(); return AdaptiveScaffold( floatingAction: HidingFloatingActionButton( key: const Key('filter'), scrollCtrl: _scrollCtrl, child: CharacterMediaFilterButton(widget.id, ref), ), child: switch (Theming.of(context).formFactor) { .phone => _CompactView( id: widget.id, imageUrl: widget.imageUrl, ref: ref, highContrast: options.highContrast, character: character, scrollCtrl: _scrollCtrl, toggleFavorite: toggleFavorite, ), .tablet => _LargeView( id: widget.id, imageUrl: widget.imageUrl, ref: ref, highContrast: options.highContrast, character: character, scrollCtrl: _scrollCtrl, toggleFavorite: toggleFavorite, ), }, ); } } class _CompactView extends StatefulWidget { const _CompactView({ required this.id, required this.imageUrl, required this.ref, required this.highContrast, required this.character, required this.scrollCtrl, required this.toggleFavorite, }); final int id; final String? imageUrl; final WidgetRef ref; final bool highContrast; final AsyncValue character; final PagedController scrollCtrl; final Future Function() toggleFavorite; @override State<_CompactView> createState() => _CompactViewState(); } class _CompactViewState extends State<_CompactView> with SingleTickerProviderStateMixin { late final _tabCtrl = TabController(length: CharacterHeader.tabsWithOverview.length, vsync: this); @override void initState() { super.initState(); widget.scrollCtrl.loadMore = () { if (_tabCtrl.index > 0) { widget.ref.read(characterMediaProvider(widget.id).notifier).fetch(_tabCtrl.index == 1); } }; } @override void dispose() { _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); final header = CharacterHeader.withTabBar( id: widget.id, imageUrl: widget.imageUrl, character: widget.character.value, tabCtrl: _tabCtrl, scrollToTop: widget.scrollCtrl.scrollToTop, toggleFavorite: widget.toggleFavorite, highContrast: widget.highContrast, ); return NestedScrollView( controller: widget.scrollCtrl, headerSliverBuilder: (context, _) => [header], body: MediaQuery( data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)), child: widget.character.unwrapPrevious().when( loading: () => const Center(child: Loader()), error: (_, _) => const Center(child: Text('Failed to load character')), data: (data) => _CharacterTabs.withOverview( id: widget.id, character: data, tabCtrl: _tabCtrl, highContrast: widget.highContrast, ), ), ), ); } } class _LargeView extends StatefulWidget { const _LargeView({ required this.id, required this.imageUrl, required this.ref, required this.highContrast, required this.character, required this.scrollCtrl, required this.toggleFavorite, }); final int id; final String? imageUrl; final WidgetRef ref; final bool highContrast; final AsyncValue character; final PagedController scrollCtrl; final Future Function() toggleFavorite; @override State<_LargeView> createState() => _LargeViewState(); } class _LargeViewState extends State<_LargeView> with SingleTickerProviderStateMixin { late final _tabCtrl = TabController( length: CharacterHeader.tabsWithoutOverview.length, vsync: this, ); @override void initState() { super.initState(); widget.scrollCtrl.loadMore = () { widget.ref.read(characterMediaProvider(widget.id).notifier).fetch(_tabCtrl.index == 0); }; } @override void dispose() { _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final header = CharacterHeader.withoutTabBar( id: widget.id, imageUrl: widget.imageUrl, character: widget.character.value, toggleFavorite: widget.toggleFavorite, highContrast: widget.highContrast, ); return DualPaneWithTabBar( tabCtrl: _tabCtrl, scrollToTop: widget.scrollCtrl.scrollToTop, tabs: CharacterHeader.tabsWithoutOverview, leftPane: widget.character.unwrapPrevious().when( loading: () => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ header, const SliverFillRemaining(child: Center(child: Loader())), ], ), error: (_, _) => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ header, const SliverFillRemaining(child: Center(child: Text('Failed to load character'))), ], ), data: (data) => CharacterOverviewSubview.withHeader( character: data, header: header, highContrast: widget.highContrast, invalidate: () => widget.ref.invalidate(characterProvider(widget.id)), ), ), rightPane: widget.character.unwrapPrevious().maybeWhen( data: (data) => _CharacterTabs.withoutOverview( id: widget.id, character: data, tabCtrl: _tabCtrl, scrollCtrl: widget.scrollCtrl, highContrast: widget.highContrast, ), orElse: () => const SizedBox(), ), ); } } class _CharacterTabs extends ConsumerStatefulWidget { const _CharacterTabs.withOverview({ required this.id, required this.character, required this.tabCtrl, required this.highContrast, }) : withOverview = true, scrollCtrl = null; const _CharacterTabs.withoutOverview({ required this.id, required this.character, required this.tabCtrl, required this.highContrast, required ScrollController this.scrollCtrl, }) : withOverview = false; final int id; final Character character; final TabController tabCtrl; final ScrollController? scrollCtrl; final bool highContrast; final bool withOverview; @override ConsumerState<_CharacterTabs> createState() => __CharacterViewContentState(); } class __CharacterViewContentState extends ConsumerState<_CharacterTabs> { late final ScrollController _scrollCtrl; double _lastMaxExtent = 0; @override void initState() { super.initState(); _scrollCtrl = widget.scrollCtrl ?? context.findAncestorStateOfType()!.innerController; _scrollCtrl.addListener(_scrollListener); widget.tabCtrl.addListener(_tabListener); } @override void dispose() { _scrollCtrl.removeListener(_scrollListener); widget.tabCtrl.removeListener(_tabListener); super.dispose(); } void _tabListener() { _lastMaxExtent = 0; // This is a workaround for an issue with [NestedScrollView]. // If you switch to a tab with pagination, where the content // doesn't fill the view, the scroll controller has it's maximum // extent set to 0 and the loading of a next page of items is not triggered. // This is why we need to manually load the second page. if (!widget.tabCtrl.indexIsChanging && _scrollCtrl.hasClients) { final pos = _scrollCtrl.positions.last; if (pos.minScrollExtent == pos.maxScrollExtent) _loadNextPage(); } } void _scrollListener() { final pos = _scrollCtrl.positions.last; if (pos.pixels < pos.maxScrollExtent - 100) return; if (_lastMaxExtent == pos.maxScrollExtent) return; _lastMaxExtent = pos.maxScrollExtent; _loadNextPage(); } void _loadNextPage() { final index = widget.withOverview ? widget.tabCtrl.index : widget.tabCtrl.index + 1; if (index > 0) { ref.read(characterMediaProvider(widget.id).notifier).fetch(index == 1); } } @override Widget build(BuildContext context) { ref.watch(characterMediaProvider(widget.id).select((_) => null)); final options = ref.watch(persistenceProvider.select((s) => s.options)); return TabBarView( controller: widget.tabCtrl, children: [ if (widget.withOverview) ConstrainedView( padded: false, child: CharacterOverviewSubview.asFragment( character: widget.character, scrollCtrl: _scrollCtrl, invalidate: () => ref.invalidate(characterProvider(widget.id)), highContrast: widget.highContrast, ), ), CharacterAnimeSubview( id: widget.id, scrollCtrl: _scrollCtrl, highContrast: options.highContrast, ), CharacterMangaSubview( id: widget.id, scrollCtrl: _scrollCtrl, highContrast: options.highContrast, ), ], ); } } ================================================ FILE: lib/feature/collection/collection_entries_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/collection/collection_filter_model.dart'; import 'package:otraku/feature/collection/collection_filter_provider.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/collection/collection_provider.dart'; import 'package:otraku/feature/tag/tag_model.dart'; import 'package:otraku/feature/tag/tag_provider.dart'; final collectionEntriesProvider = Provider.autoDispose.family, CollectionTag>(( ref, CollectionTag tag, ) { final filter = ref.watch(collectionFilterProvider(tag)); final mediaFilter = filter.mediaFilter; final search = filter.search.toLowerCase(); ref .watch(collectionProvider(tag).notifier) .ensureSorted(mediaFilter.sort, mediaFilter.previewSort); final lists = switch (ref.watch(collectionProvider(tag)).unwrapPrevious().value) { PreviewCollection c => [c.list], FullCollection c => c.index < 0 ? c.lists : [c.lists[c.index]], null => const [], }; final tags = ref.watch(tagsProvider).value; return _filter(lists, mediaFilter, search, tags); }); List _filter( List lists, CollectionMediaFilter mediaFilter, String search, TagCollection? tags, ) { final filteredLists = []; final releaseStartFrom = mediaFilter.startYearFrom != null ? DateTime(mediaFilter.startYearFrom!) : DateTime(1920); final releaseStartTo = mediaFilter.startYearTo != null ? DateTime(mediaFilter.startYearTo! + 1) : DateTime.now().add(const Duration(days: 900)); var tagIdIn = const []; var tagIdNotIn = const []; if (tags != null) { final tagFinder = (String name) => tags.ids[tags.indexByName[name] ?? 0]; tagIdIn = mediaFilter.tagIn.map(tagFinder).toList(); tagIdNotIn = mediaFilter.tagNotIn.map(tagFinder).toList(); } for (final l in lists) { final entries = []; for (final entry in l.entries) { if (search.isNotEmpty) { bool contains = false; for (final title in entry.titles) { if (title.toLowerCase().contains(search)) { contains = true; break; } } if (!contains && entry.notes.toLowerCase().contains(search)) { contains = true; } if (!contains) continue; } if (mediaFilter.country != null && entry.country != mediaFilter.country!.code) { continue; } if (mediaFilter.formats.isNotEmpty && !mediaFilter.formats.contains(entry.format)) { continue; } if (mediaFilter.statuses.isNotEmpty && !mediaFilter.statuses.contains(entry.releaseStatus)) { continue; } if (entry.releaseStart != null) { if (releaseStartFrom.isAfter(entry.releaseStart!)) continue; if (releaseStartTo.isBefore(entry.releaseStart!)) continue; } if (mediaFilter.genreIn.isNotEmpty) { bool isIn = true; for (final genre in mediaFilter.genreIn) { if (!entry.genres.contains(genre)) { isIn = false; break; } } if (!isIn) continue; } if (mediaFilter.genreNotIn.isNotEmpty) { bool isIn = false; for (final genre in mediaFilter.genreNotIn) { if (entry.genres.contains(genre)) { isIn = true; break; } } if (isIn) continue; } if (tagIdIn.isNotEmpty) { bool isIn = true; for (final tagId in tagIdIn) { if (!entry.tagIds.contains(tagId)) { isIn = false; break; } } if (!isIn) continue; } if (tagIdNotIn.isNotEmpty) { bool isIn = false; for (final tagId in tagIdNotIn) { if (entry.tagIds.contains(tagId)) { isIn = true; break; } } if (isIn) continue; } if (mediaFilter.isPrivate != null && entry.isPrivate != mediaFilter.isPrivate) { continue; } if (mediaFilter.hasNotes != null && entry.notes.isNotEmpty != mediaFilter.hasNotes) { continue; } entries.add(entry); } if (entries.isNotEmpty) { filteredLists.add(l.copyWithEntries(entries)); } } return filteredLists; } ================================================ FILE: lib/feature/collection/collection_filter_model.dart ================================================ import 'package:otraku/extension/enum_extension.dart'; import 'package:otraku/feature/media/media_models.dart'; class CollectionFilter { const CollectionFilter._({required this.search, required this.mediaFilter}); CollectionFilter(this.mediaFilter) : search = ''; final String search; final CollectionMediaFilter mediaFilter; CollectionFilter copyWith({String? search, CollectionMediaFilter? mediaFilter}) => CollectionFilter._( search: search ?? this.search, mediaFilter: mediaFilter ?? this.mediaFilter, ); } class CollectionMediaFilter { CollectionMediaFilter() : sort = .title, previewSort = .title; factory CollectionMediaFilter.fromPersistenceMap(Map map) { final sort = EntrySort.values.getOrFirst(map['sort']); final previewSort = EntrySort.values.getOrFirst(map['previewSort']); final filter = CollectionMediaFilter() ..sort = sort ..previewSort = previewSort ..startYearFrom = map['startYearFrom'] ..startYearTo = map['startYearTo'] ..country = OriginCountry.values.getOrNull(map['country']) ..isPrivate = map['isPrivate'] ..hasNotes = map['hasNotes']; for (final e in map['statuses'] ?? const []) { final status = ReleaseStatus.values.getOrNull(e); if (status != null) { filter.statuses.add(status); } } for (final e in map['formats'] ?? const []) { final format = MediaFormat.values.getOrNull(e); if (format != null) { filter.formats.add(format); } } filter.genreIn.addAll(map['genreIn'] ?? const []); filter.genreNotIn.addAll(map['genreNotIn'] ?? const []); filter.tagIn.addAll(map['tagIn'] ?? const []); filter.tagNotIn.addAll(map['tagNotIn'] ?? const []); return filter; } final statuses = []; final formats = []; final genreIn = []; final genreNotIn = []; final tagIn = []; final tagNotIn = []; EntrySort sort; EntrySort previewSort; int? startYearFrom; int? startYearTo; OriginCountry? country; bool? isPrivate; bool? hasNotes; bool get isActive => statuses.isNotEmpty || formats.isNotEmpty || genreIn.isNotEmpty || genreNotIn.isNotEmpty || tagIn.isNotEmpty || tagNotIn.isNotEmpty || startYearFrom != null || startYearTo != null || country != null || isPrivate != null || hasNotes != null; CollectionMediaFilter copy() => CollectionMediaFilter() ..sort = sort ..previewSort = previewSort ..statuses.addAll(statuses) ..formats.addAll(formats) ..genreIn.addAll(genreIn) ..genreNotIn.addAll(genreNotIn) ..tagIn.addAll(tagIn) ..tagNotIn.addAll(tagNotIn) ..startYearFrom = startYearFrom ..startYearTo = startYearTo ..country = country ..isPrivate = isPrivate ..hasNotes = hasNotes; Map toPersistenceMap() => { 'statuses': statuses.map((e) => e.index).toList(), 'formats': formats.map((e) => e.index).toList(), 'genreIn': genreIn, 'genreNotIn': genreNotIn, 'tagIn': tagIn, 'tagNotIn': tagNotIn, 'sort': sort.index, 'previewSort': previewSort.index, 'startYearFrom': startYearFrom, 'startYearTo': startYearTo, 'country': country?.index, 'isPrivate': isPrivate, 'hasNotes': hasNotes, }; } ================================================ FILE: lib/feature/collection/collection_filter_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/collection/collection_filter_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/collection/collection_models.dart'; final collectionFilterProvider = NotifierProvider.autoDispose .family( CollectionFilterNotifier.new, ); class CollectionFilterNotifier extends Notifier { CollectionFilterNotifier(this.arg); final CollectionTag arg; @override CollectionFilter build() { final mediaFilter = ref.watch( persistenceProvider.select( (s) => arg.ofAnime ? s.animeCollectionMediaFilter : s.mangaCollectionMediaFilter, ), ); return CollectionFilter(mediaFilter.copy()); } CollectionFilter update(CollectionFilter Function(CollectionFilter) callback) => state = callback(state); } ================================================ FILE: lib/feature/collection/collection_filter_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/collection/collection_filter_model.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/feature/tag/tag_picker.dart'; import 'package:otraku/widget/input/year_range_picker.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/tag/tag_provider.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/navigation_tool.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/sheets.dart'; class CollectionFilterView extends ConsumerStatefulWidget { const CollectionFilterView({required this.tag, required this.filter, required this.onChanged}); final CollectionTag tag; final CollectionMediaFilter filter; final void Function(CollectionMediaFilter) onChanged; @override ConsumerState createState() => _FilterCollectionViewState(); } class _FilterCollectionViewState extends ConsumerState { late final _filter = widget.filter.copy(); @override Widget build(BuildContext context) { final options = ref.watch(persistenceProvider.select((s) => s.options)); final ofViewer = ref.watch(viewerIdProvider) == widget.tag.userId; final applyButton = BottomBarButton( text: 'Apply', icon: Icons.done_rounded, onTap: () { widget.onChanged(_filter); Navigator.pop(context); }, ); final revertToDefaultButton = BottomBarButton( text: 'Reset', icon: Icons.restore_rounded, foregroundColor: ColorScheme.of(context).secondary, onTap: () { final persistence = ref.read(persistenceProvider); if (widget.tag.ofAnime) { widget.onChanged(persistence.animeCollectionMediaFilter); } else { widget.onChanged(persistence.mangaCollectionMediaFilter); } Navigator.pop(context); }, ); final saveButton = BottomBarButton( text: 'Save', icon: Icons.save_outlined, foregroundColor: ColorScheme.of(context).secondary, onTap: () => ConfirmationDialog.show( context, title: 'Make default?', content: 'The current filters and sorting will become the default.', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: () { final notifier = ref.read(persistenceProvider.notifier); if (widget.tag.ofAnime) { notifier.setAnimeCollectionMediaFilter(_filter); } else { notifier.setMangaCollectionMediaFilter(_filter); } widget.onChanged(_filter); Navigator.pop(context); }, ), ); Widget? previewSortPicker; if (ofViewer && (widget.tag.ofAnime && options.animeCollectionPreview || !widget.tag.ofAnime && options.mangaCollectionPreview)) { previewSortPicker = EntrySortChipSelector( title: 'Preview Sorting', value: _filter.previewSort, onChanged: (v) => _filter.previewSort = v, highContrast: options.highContrast, ); } return SheetWithButtonRow( buttons: BottomBar( Theming.of(context).rightButtonOrientation ? [saveButton, revertToDefaultButton, applyButton] : [applyButton, revertToDefaultButton, saveButton], ), builder: (context, scrollCtrl) => Padding( padding: const .symmetric(horizontal: Theming.offset), child: ListView( controller: scrollCtrl, padding: const .only(top: 20), children: [ EntrySortChipSelector( title: 'Sorting', value: _filter.sort, onChanged: (v) => _filter.sort = v, highContrast: options.highContrast, ), ?previewSortPicker, ChipMultiSelector( title: 'Statuses', items: ReleaseStatus.values.map((v) => (v.label, v)).toList(), values: _filter.statuses, highContrast: options.highContrast, ), ChipMultiSelector( title: 'Formats', items: (widget.tag.ofAnime ? MediaFormat.animeFormats : MediaFormat.mangaFormats) .map((v) => (v.label, v)) .toList(), values: _filter.formats, highContrast: options.highContrast, ), const SizedBox(height: 5), const Divider(), switch (ref.watch(tagsProvider)) { AsyncData() => TagPicker( includedGenres: _filter.genreIn, excludedGenres: _filter.genreNotIn, includedTags: _filter.tagIn, excludedTags: _filter.tagNotIn, ), AsyncError(:final error) => Center( child: Padding( padding: Theming.paddingAll, child: Text('Failed to load tags: $error'), ), ), AsyncLoading() => const Center( child: Padding(padding: Theming.paddingAll, child: Loader()), ), }, const Divider(), const SizedBox(height: Theming.offset), YearRangePicker( title: 'Release Year Range', from: _filter.startYearFrom, to: _filter.startYearTo, onChanged: (from, to) { _filter.startYearFrom = from; _filter.startYearTo = to; }, ), const SizedBox(height: Theming.offset), const Divider(), ChipSelector( title: 'Country', items: OriginCountry.values.map((v) => (v.label, v)).toList(), value: _filter.country, onChanged: (v) => _filter.country = v, highContrast: options.highContrast, ), if (ofViewer) ChipSelector( title: 'Visibility', items: const [('Private', true), ('Public', false)], value: _filter.isPrivate, onChanged: (v) => _filter.isPrivate = v, highContrast: options.highContrast, ), ChipSelector( title: 'Notes', items: const [('With Notes', true), ('Without Notes', false)], value: _filter.hasNotes, onChanged: (v) => _filter.hasNotes = v, highContrast: options.highContrast, ), SizedBox( height: MediaQuery.paddingOf(context).bottom + BottomBar.height + Theming.offset, ), ], ), ), ); } } ================================================ FILE: lib/feature/collection/collection_floating_action.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/collection/collection_provider.dart'; import 'package:otraku/feature/home/home_provider.dart'; import 'package:otraku/widget/input/pill_selector.dart'; import 'package:otraku/widget/swipe_switcher.dart'; import 'package:otraku/widget/sheets.dart'; class CollectionFloatingAction extends StatelessWidget { CollectionFloatingAction(this.tag) : super(key: Key('${tag.userId}${tag.ofAnime}')); final CollectionTag tag; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { final collection = ref.watch( collectionProvider(tag).select((s) => s.unwrapPrevious().value), ); return switch (collection) { null => const SizedBox(), PreviewCollection _ => FloatingActionButton( tooltip: 'Load Entire Collection', child: const Icon(Ionicons.enter_outline), onPressed: () => ref.read(homeProvider.notifier).expandCollection(tag.ofAnime), ), FullCollection c => _fullCollectionActionButton(context, ref, c.lists, c.index), }; }, ); } Widget _fullCollectionActionButton( BuildContext context, WidgetRef ref, List lists, int index, ) { final items = buildFullCollectionSelectionItems(context, lists); return FloatingActionButton( tooltip: 'Lists', onPressed: () { showSheet( context, SimpleSheet( initialHeight: PillSelector.expectedMinHeight(lists.length), builder: (context, scrollCtrl) => PillSelector( scrollCtrl: scrollCtrl, selected: index + 1, items: items, onTap: (index) { ref.read(collectionProvider(tag).notifier).changeIndex(index - 1); Navigator.pop(context); }, ), ), ); }, child: SwipeSwitcher( index: index + 1, children: List.filled(lists.length + 1, const Icon(Ionicons.menu_outline)), onChanged: (index) => ref.read(collectionProvider(tag).notifier).changeIndex(index - 1), ), ); } } List buildFullCollectionSelectionItems(BuildContext context, List lists) { final listItems = [ (name: 'All', count: lists.fold(0, (v, l) => v + l.entries.length).toString()), ...lists.map((l) => (name: l.name, count: l.entries.length.toString())), ]; final listItemToWidget = (({String name, String count}) item) => Row( spacing: 5, children: [ Expanded(child: Text(item.name)), Text(item.count, style: TextTheme.of(context).labelMedium), ], ); return listItems.map(listItemToWidget).toList(); } ================================================ FILE: lib/feature/collection/collection_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/edit/edit_view.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/util/debounce.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/sheets.dart'; class CollectionGrid extends StatelessWidget { const CollectionGrid({ required this.items, required this.onProgressUpdated, required this.highContrast, }); final List items; final Future Function(Entry, bool)? onProgressUpdated; final bool highContrast; @override Widget build(BuildContext context) { final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final extraHeight = lineHeight * 2 + 38; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight( minWidth: 100, extraHeight: extraHeight, rawHWRatio: Theming.coverHtoWRatio, ), delegate: SliverChildBuilderDelegate( childCount: items.length, (context, i) => CardExtension.highContrast(highContrast)( child: MediaRouteTile( id: items[i].mediaId, imageUrl: items[i].imageUrl, child: Column( crossAxisAlignment: .stretch, children: [ Expanded( child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall), child: Container( color: ColorScheme.of(context).surfaceContainerHighest, child: CachedImage(items[i].imageUrl), ), ), ), SizedBox( height: lineHeight * 2 + 8, child: Padding( padding: const .only(left: 5, right: 5, top: 5, bottom: 3), child: Text(items[i].titles[0], overflow: .ellipsis, maxLines: 2), ), ), _IncrementButton(items[i], onProgressUpdated), ], ), ), ), ), ); } } class _IncrementButton extends StatefulWidget { const _IncrementButton(this.item, this.onProgressUpdated); final Entry item; final Future Function(Entry, bool)? onProgressUpdated; @override State<_IncrementButton> createState() => _IncrementButtonState(); } class _IncrementButtonState extends State<_IncrementButton> { final _debounce = Debounce(); int? _lastProgress; @override Widget build(BuildContext context) { final item = widget.item; if (item.progress == item.progressMax) { return Tooltip( message: 'Progress', child: SizedBox( height: 30, child: Center( child: Text(item.progress.toString(), style: TextTheme.of(context).labelSmall), ), ), ); } final foregroundColor = item.nextEpisode != null && item.progress + 1 < item.nextEpisode! ? ColorScheme.of(context).error : null; if (widget.onProgressUpdated == null) { return Tooltip( message: 'Progress', child: SizedBox( height: 30, child: Center( child: Text( '${item.progress}/${item.progressMax ?? "?"}', style: TextTheme.of(context).labelSmall?.copyWith(color: foregroundColor), ), ), ), ); } return TextButton( style: TextButton.styleFrom( minimumSize: const Size(0, 30), padding: const .symmetric(horizontal: 5), tapTargetSize: MaterialTapTargetSize.shrinkWrap, foregroundColor: foregroundColor, iconColor: foregroundColor, ), onPressed: () { _debounce.cancel(); if (item.progressMax != null && item.progress >= item.progressMax! - 1) { _resetProgress(); showSheet(context, EditView((id: item.mediaId, setComplete: true))); return; } _lastProgress ??= item.progress; setState(() => item.progress++); _debounce.run(_update); }, child: Tooltip( message: 'Increment Progress', child: Row( mainAxisAlignment: .center, children: [ Text( '${item.progress}/${item.progressMax ?? "?"}', style: const TextStyle(fontSize: Theming.fontSmall), ), const SizedBox(width: 3), const Icon(Ionicons.add_outline, size: Theming.iconSmall), ], ), ), ); } void _update() async { final item = widget.item; var updateStatus = false; if (_lastProgress == 0 && (item.listStatus == .planning || item.listStatus == .paused || item.listStatus == .dropped)) { await ConfirmationDialog.show( context, title: 'Update status?', content: 'Do you also want to update the list status?', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: () => updateStatus = true, ); } final err = await widget.onProgressUpdated!(item, updateStatus); if (err == null) { _lastProgress = null; return; } _resetProgress(); if (mounted) { SnackBarExtension.show(context, 'Failed updating progress: $err'); } } void _resetProgress() { if (_lastProgress == null) return; setState(() => widget.item.progress = _lastProgress!); _lastProgress = null; } } ================================================ FILE: lib/feature/collection/collection_list.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/util/debounce.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/edit/edit_view.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/input/note_label.dart'; import 'package:otraku/widget/input/score_label.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/widget/text_rail.dart'; import 'package:otraku/feature/media/media_models.dart'; class CollectionList extends StatelessWidget { const CollectionList({ required this.items, required this.scoreFormat, required this.onProgressUpdated, required this.highContrast, }); final List items; final ScoreFormat scoreFormat; final Future Function(Entry, bool)? onProgressUpdated; final bool highContrast; @override Widget build(BuildContext context) { final textTheme = TextTheme.of(context); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!); final tileHeight = bodyMediumLineHeight * 2 + labelMediumLineHeight * 2 + Theming.offset + 69; return SliverFixedExtentList( delegate: SliverChildBuilderDelegate( (_, i) => _Tile( items[i], scoreFormat, onProgressUpdated, highContrast, tileHeight / Theming.coverHtoWRatio, ), childCount: items.length, ), itemExtent: tileHeight, ); } } class _Tile extends StatelessWidget { const _Tile( this.entry, this.scoreFormat, this.onProgressUpdated, this.highContrast, this.coverWidth, ); final Entry entry; final ScoreFormat scoreFormat; final Future Function(Entry, bool)? onProgressUpdated; final bool highContrast; final double coverWidth; @override Widget build(BuildContext context) { return CardExtension.highContrast(highContrast)( margin: const .only(bottom: Theming.offset), child: MediaRouteTile( key: ValueKey(entry.mediaId), id: entry.mediaId, imageUrl: entry.imageUrl, child: Row( crossAxisAlignment: .start, children: [ ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: DecoratedBox( decoration: BoxDecoration(color: ColorScheme.of(context).surfaceContainerHighest), child: CachedImage(entry.imageUrl, width: coverWidth), ), ), Expanded( child: Padding( padding: Theming.paddingAll, child: _TileContent(entry, scoreFormat, onProgressUpdated), ), ), ], ), ), ); } } /// The content is a [StatefulWidget], as it /// needs to update when the progress increments. class _TileContent extends StatefulWidget { const _TileContent(this.item, this.scoreFormat, this.onProgressUpdated); final Entry item; final ScoreFormat scoreFormat; final Future Function(Entry, bool)? onProgressUpdated; @override State<_TileContent> createState() => __TileContentState(); } class __TileContentState extends State<_TileContent> { final _debounce = Debounce(); int? _lastProgress; @override Widget build(BuildContext context) { final colorScheme = ColorScheme.of(context); final item = widget.item; double progressPercent = 0; if (item.progressMax != null) { progressPercent = item.progress / item.progressMax!; } else if (item.nextEpisode != null) { progressPercent = item.progress / (item.nextEpisode! - 1); } else if (item.progress > 0) { progressPercent = 1; } final textRailItems = {}; if (item.format != null) { textRailItems[item.format!.label] = false; } if (item.airingAt != null) { final key = 'Ep ${item.nextEpisode} in ${item.airingAt!.timeUntil}'; textRailItems[key] = false; } if (item.nextEpisode != null && item.nextEpisode! - 1 > item.progress) { final key = '${item.nextEpisode! - 1 - item.progress} ep behind'; textRailItems[key] = true; } return Column( mainAxisAlignment: .spaceAround, crossAxisAlignment: .stretch, children: [ Flexible(child: Text(widget.item.titles[0], overflow: .ellipsis, maxLines: 2)), TextRail(textRailItems, maxLines: 2), Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: SizedBox( height: 3, child: DecoratedBox( decoration: BoxDecoration( borderRadius: Theming.borderRadiusSmall, gradient: LinearGradient( colors: [ colorScheme.onSurfaceVariant, colorScheme.onSurfaceVariant, colorScheme.surfaceContainerHighest, colorScheme.surfaceContainerHighest, ], stops: [0.0, progressPercent, progressPercent, 1.0], ), ), ), ), ), Row( mainAxisAlignment: .spaceBetween, children: [ ScoreLabel(item.score, widget.scoreFormat), if (item.repeat > 0) Tooltip( message: 'Repeats', child: Row( mainAxisSize: .min, spacing: 3, children: [ const Icon(Ionicons.repeat, size: Theming.iconSmall), Text(item.repeat.toString(), style: TextTheme.of(context).labelSmall), ], ), ) else const SizedBox(), NotesLabel(item.notes), _buildProgressButton(context), ], ), ], ); } Widget _buildProgressButton(BuildContext context) { final item = widget.item; final foregroundColor = item.nextEpisode != null && item.progress + 1 < item.nextEpisode! ? ColorScheme.of(context).error : ColorScheme.of(context).onSurfaceVariant; final text = Text( item.progress == item.progressMax ? item.progress.toString() : '${item.progress}/${item.progressMax ?? "?"}', style: TextTheme.of(context).labelSmall?.copyWith(color: foregroundColor), ); if (widget.onProgressUpdated == null || item.progress == item.progressMax) { return Tooltip(message: 'Progress', child: text); } return TextButton( style: TextButton.styleFrom( minimumSize: const Size(0, 40), tapTargetSize: MaterialTapTargetSize.shrinkWrap, foregroundColor: foregroundColor, iconColor: foregroundColor, ), onPressed: () { _debounce.cancel(); if (item.progressMax != null && item.progress >= item.progressMax! - 1) { _resetProgress(); showSheet(context, EditView((id: item.mediaId, setComplete: true))); return; } _lastProgress ??= item.progress; setState(() => item.progress++); _debounce.run(_update); }, child: Tooltip( message: 'Increment Progress', child: Row( spacing: 3, children: [ text, const Icon(Ionicons.add_outline, size: Theming.iconSmall), ], ), ), ); } void _update() async { final item = widget.item; var updateStatus = false; if (_lastProgress == 0 && (item.listStatus == .planning || item.listStatus == .paused || item.listStatus == .dropped)) { await ConfirmationDialog.show( context, title: 'Update status?', content: 'Do you also want to update the list status?', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: () => updateStatus = true, ); } final err = await widget.onProgressUpdated!(item, updateStatus); if (err == null) { _lastProgress = null; return; } _resetProgress(); if (mounted) { SnackBarExtension.show(context, 'Failed updating progress: $err'); } } void _resetProgress() { if (_lastProgress == null) return; setState(() => widget.item.progress = _lastProgress!); _lastProgress = null; } } ================================================ FILE: lib/feature/collection/collection_models.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/extension/iterable_extension.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/feature/media/media_models.dart'; typedef CollectionTag = ({int userId, bool ofAnime}); enum CollectionItemView { detailed, simple } sealed class Collection { const Collection({required this.scoreFormat}); final ScoreFormat scoreFormat; String get listName; void sort(EntrySort s); } class PreviewCollection extends Collection { const PreviewCollection._({required this.list, required super.scoreFormat}); factory PreviewCollection(Map map, ImageQuality imageQuality) { final entries = []; for (final l in map['lists']) { if (l['isCustomList']) continue; for (final e in l['entries']) { entries.add(Entry(e, imageQuality)); } } return PreviewCollection._( list: EntryList._( name: 'Preview', entries: entries, status: null, splitCompletedListFormat: null, ), scoreFormat: ScoreFormat.from(map['user']['mediaListOptions']['scoreFormat']), ); } final EntryList list; @override String get listName => 'Preview'; @override void sort(EntrySort s) { list.entries.sort(_entryComparator(s)); } } class FullCollection extends Collection { const FullCollection._({required this.lists, required this.index, required super.scoreFormat}); factory FullCollection( Map map, bool ofAnime, int index, ImageQuality imageQuality, ) { final maps = map['lists'] as List; final lists = []; final metaData = map['user']['mediaListOptions'][ofAnime ? 'animeList' : 'mangaList']; bool splitCompleted = metaData['splitCompletedSectionByFormat'] ?? false; for (final String section in metaData['sectionOrder']) { final pos = maps.indexWhere((l) => l['name'] == section); if (pos == -1) continue; final l = maps.removeAt(pos); lists.add(EntryList(l, splitCompleted, imageQuality)); } for (final l in maps) { lists.add(EntryList(l, splitCompleted, imageQuality)); } if (index >= lists.length) index = 0; return FullCollection._( lists: lists, index: index, scoreFormat: ScoreFormat.from(map['user']['mediaListOptions']['scoreFormat']), ); } final List lists; final int index; @override String get listName => index < 0 ? 'All' : lists[index].name; @override void sort(EntrySort s) { final comparator = _entryComparator(s); for (final l in lists) { l.entries.sort(comparator); } } FullCollection withIndex(int newIndex) => newIndex == index ? this : FullCollection._(lists: lists, index: newIndex, scoreFormat: scoreFormat); } class EntryList { const EntryList._({ required this.name, required this.entries, required this.status, required this.splitCompletedListFormat, }); factory EntryList(Map map, bool splitCompleted, ImageQuality imageQuality) { final status = !map['isCustomList'] ? ListStatus.from(map['status']) : null; return EntryList._( name: map['name'], status: status, splitCompletedListFormat: splitCompleted && status == .completed ? MediaFormat.from(map['entries'][0]['media']['format']) : null, entries: (map['entries'] as List).map((e) => Entry(e, imageQuality)).toList(), ); } final String name; final List entries; /// The [ListStatus] of the [entries] in this list. /// If `null`, this is a custom list. final ListStatus? status; /// If the user's "completed" list is split by format and this is one of the /// resulting lists, [splitCompletedListFormat] is the corresponding format. final MediaFormat? splitCompletedListFormat; bool setByMediaId(Entry entry) { for (int i = 0; i < entries.length; i++) { if (entries[i].mediaId == entry.mediaId) { entries[i] = entry; return true; } } return false; } void removeByMediaId(int id) { for (int i = 0; i < entries.length; i++) { if (entries[i].mediaId == id) { entries.removeAt(i); return; } } } void insertSorted(Entry entry, EntrySort s) { final compare = _entryComparator(s); for (int i = 0; i < entries.length; i++) { if (compare(entry, entries[i]) <= 0) { entries.insert(i, entry); return; } } entries.add(entry); } void sort(EntrySort s) => entries.sort(_entryComparator(s)); EntryList copyWithEntries(List entries) => EntryList._( name: name, entries: entries, status: status, splitCompletedListFormat: splitCompletedListFormat, ); } /// Returns a [Comparator] for [Entry], based on an [EntrySort]. int Function(Entry, Entry) _entryComparator(EntrySort s) => switch (s) { .title => (a, b) => a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()), .titleDesc => (a, b) => b.titles[0].compareTo(a.titles[0]), .score => (a, b) { final comparison = a.score.compareTo(b.score); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .scoreDesc => (a, b) { final comparison = b.score.compareTo(a.score); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .updated => (a, b) { final comparison = a.updatedAt!.compareTo(b.updatedAt!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .updatedDesc => (a, b) { final comparison = b.updatedAt!.compareTo(a.updatedAt!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .added => (a, b) { final comparison = a.createdAt!.compareTo(b.createdAt!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .addedDesc => (a, b) { final comparison = b.createdAt!.compareTo(a.createdAt!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .progress => (a, b) { final comparison = a.progress.compareTo(b.progress); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .progressDesc => (a, b) { final comparison = b.progress.compareTo(a.progress); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .repeated => (a, b) { final comparison = a.repeat.compareTo(b.repeat); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .repeatedDesc => (a, b) { final comparison = b.repeat.compareTo(a.repeat); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .airing => (a, b) { if (a.airingAt == null) { if (b.airingAt == null) { return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); } return 1; } if (b.airingAt == null) return -1; final comparison = a.airingAt!.compareTo(b.airingAt!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .airingDesc => (a, b) { if (b.airingAt == null) { if (a.airingAt == null) { return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); } return -1; } if (a.airingAt == null) return 1; final comparison = b.airingAt!.compareTo(a.airingAt!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .releasedOn => (a, b) { if (a.releaseStart == null) { if (b.releaseStart == null) { return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); } return 1; } if (b.releaseStart == null) return -1; final comparison = a.releaseStart!.compareTo(b.releaseStart!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .releasedOnDesc => (a, b) { if (b.releaseStart == null) { if (a.releaseStart == null) { return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); } return -1; } if (a.releaseStart == null) return 1; final comparison = b.releaseStart!.compareTo(a.releaseStart!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .startedOn => (a, b) { if (a.watchStart == null) { if (b.watchStart == null) { return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); } return 1; } if (b.watchStart == null) return -1; final comparison = a.watchStart!.compareTo(b.watchStart!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .startedOnDesc => (a, b) { if (b.watchStart == null) { if (a.watchStart == null) { return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); } return -1; } if (a.watchStart == null) return 1; final comparison = b.watchStart!.compareTo(a.watchStart!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .completedOn => (a, b) { if (a.watchEnd == null) { if (b.watchEnd == null) { return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); } return 1; } if (b.watchEnd == null) return -1; final comparison = a.watchEnd!.compareTo(b.watchEnd!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .completedOnDesc => (a, b) { if (b.watchEnd == null) { if (a.watchEnd == null) { return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); } return -1; } if (a.watchEnd == null) return 1; final comparison = b.watchEnd!.compareTo(a.watchEnd!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .avgScore => (a, b) { if (a.avgScore == null) { if (b.avgScore == null) { return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); } return 1; } if (b.avgScore == null) return -1; final comparison = a.avgScore!.compareTo(b.avgScore!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, .avgScoreDesc => (a, b) { if (b.avgScore == null) { if (a.avgScore == null) { return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); } return -1; } if (a.avgScore == null) return 1; final comparison = b.avgScore!.compareTo(a.avgScore!); if (comparison != 0) return comparison; return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()); }, }; class Entry { Entry._({ required this.mediaId, required this.titles, required this.imageUrl, required this.format, required this.releaseStatus, required this.listStatus, required this.nextEpisode, required this.airingAt, required this.createdAt, required this.updatedAt, required this.country, required this.isPrivate, required this.genres, required this.tagIds, required this.progressMax, required this.progress, required this.repeat, required this.score, required this.notes, required this.avgScore, required this.releaseStart, required this.watchStart, required this.watchEnd, }); factory Entry(Map map, ImageQuality imageQuality) { final titles = [map['media']['title']['userPreferred']]; if (map['media']['title']['english'] != null) { titles.add(map['media']['title']['english']); } if (map['media']['title']['romaji'] != null) { titles.add(map['media']['title']['romaji']); } if (map['media']['title']['native'] != null) { titles.add(map['media']['title']['native']); } final tagIds = []; for (final t in map['media']['tags']) { tagIds.add(t['id']); } return Entry._( mediaId: map['media']['id'], titles: titles, imageUrl: map['media']['coverImage'][imageQuality.value], format: MediaFormat.from(map['media']['format']), releaseStatus: ReleaseStatus.from(map['media']['status']), listStatus: ListStatus.from(map['status']), nextEpisode: map['media']['nextAiringEpisode']?['episode'], airingAt: DateTimeExtension.tryFromSecondsSinceEpoch( map['media']['nextAiringEpisode']?['airingAt'], ), createdAt: map['createdAt'], updatedAt: map['updatedAt'], country: map['media']['countryOfOrigin'], isPrivate: map['private'] ?? false, genres: List.from(map['media']['genres'] ?? [], growable: false), tagIds: tagIds, progressMax: map['media']['episodes'] ?? map['media']['chapters'], progress: map['progress'] ?? 0, repeat: map['repeat'] ?? 0, score: map['score'].toDouble() ?? 0.0, notes: map['notes'] ?? '', avgScore: map['media']['averageScore'], releaseStart: DateTimeExtension.fromFuzzyDate(map['media']['startDate']), watchStart: DateTimeExtension.fromFuzzyDate(map['startedAt']), watchEnd: DateTimeExtension.fromFuzzyDate(map['completedAt']), ); } final int mediaId; final List titles; final String imageUrl; final MediaFormat? format; final ReleaseStatus? releaseStatus; final ListStatus? listStatus; final int? nextEpisode; final DateTime? airingAt; final int? createdAt; final int? updatedAt; final String? country; final bool isPrivate; final List genres; final List tagIds; final int? progressMax; int progress; int repeat; double score; String notes; int? avgScore; DateTime? releaseStart; DateTime? watchStart; DateTime? watchEnd; } enum ListStatus { current('CURRENT'), planning('PLANNING'), completed('COMPLETED'), dropped('DROPPED'), paused('PAUSED'), repeating('REPEATING'); const ListStatus(this.value); final String value; String label(bool? ofAnime) => switch (this) { current => ofAnime == null ? 'Current' : ofAnime ? 'Watching' : 'Reading', repeating => ofAnime == null ? 'Repeating' : ofAnime ? 'Rewatching' : 'Rereading', completed => 'Completed', paused => 'Paused', planning => 'Planning', dropped => 'Dropped', }; static ListStatus? from(String? value) => ListStatus.values.firstWhereOrNull((v) => v.value == value); } ================================================ FILE: lib/feature/collection/collection_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/home/home_provider.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final collectionProvider = AsyncNotifierProvider.autoDispose .family(CollectionNotifier.new); class CollectionNotifier extends AsyncNotifier { CollectionNotifier(this.arg); final CollectionTag arg; var _sort = EntrySort.title; @override FutureOr build() async { final fullCollectionIndex = switch (state.value) { FullCollection c => c.index, _ => -1, }; final viewerId = ref.watch(viewerIdProvider); final isFull = arg.userId != viewerId || ref.watch( homeProvider.select( (s) => arg.ofAnime ? s.didExpandAnimeCollection : s.didExpandMangaCollection, ), ); final data = await ref.read(repositoryProvider).request(GqlQuery.collection, { 'userId': arg.userId, 'type': arg.ofAnime ? 'ANIME' : 'MANGA', if (!isFull) 'status_in': ['CURRENT', 'REPEATING'], }); final imageQuality = ref.read(persistenceProvider).options.imageQuality; final collection = isFull ? FullCollection( data['MediaListCollection'], arg.ofAnime, fullCollectionIndex, imageQuality, ) : PreviewCollection(data['MediaListCollection'], imageQuality); collection.sort(_sort); return collection; } void ensureSorted(EntrySort sort, EntrySort previewSort) { _updateState((collection) { final selectedSort = switch (collection) { FullCollection _ => sort, PreviewCollection _ => previewSort, }; if (_sort == selectedSort) return; _sort = selectedSort; collection.sort(selectedSort); return null; }); } void changeIndex(int newIndex) => _updateState( (collection) => switch (collection) { FullCollection _ => collection.withIndex(newIndex), PreviewCollection _ => collection, }, ); void removeEntry(int mediaId) { _updateState( (collection) => switch (collection) { PreviewCollection c => c..list.removeByMediaId(mediaId), FullCollection c => _withRemovedEmptyLists( c..lists.forEach((list) => list.removeByMediaId(mediaId)), ), }, ); } /// There is an api bug in entry updating, /// which prevents tag data from being returned. /// This is why [saveEntry] additionally fetches the updated entry. Future saveEntry(int mediaId, ListStatus? oldStatus) async { try { var data = await ref.read(repositoryProvider).request(GqlQuery.listEntry, { 'userId': arg.userId, 'mediaId': mediaId, }); data = data['MediaList']; final entry = Entry(data, ref.read(persistenceProvider).options.imageQuality); _updateState( (collection) => switch (collection) { FullCollection _ => _saveEntryInFullCollection(collection, entry, oldStatus, data), PreviewCollection _ => _saveEntryInPreviewCollection( collection, entry, oldStatus, entry.listStatus, ), }, ); } catch (_) {} } /// An alternative to [saveEntry], /// that only updates the progress and potentially, the list status. /// When incrementing to last episode, [saveEntry] should be called instead. Future saveEntryProgress(Entry oldEntry, bool setAsCurrent) async { try { await ref.read(repositoryProvider).request(GqlMutation.updateProgress, { 'mediaId': oldEntry.mediaId, 'progress': oldEntry.progress, if (setAsCurrent) ...{ 'status': ListStatus.current.value, if (oldEntry.watchStart == null) 'startedAt': DateTime.now().fuzzyDate, }, }); await saveEntry(oldEntry.mediaId, oldEntry.listStatus); return null; } catch (e) { return e.toString(); } } FullCollection _saveEntryInFullCollection( FullCollection collection, Entry entry, ListStatus? oldStatus, Map data, ) { final hiddenFromStatusLists = data['hiddenFromStatusLists'] ?? false; final customListItems = data['customLists'] ?? const {}; final customLists = customListItems.entries .where((e) => e.value == true) .map((e) => e.key.toLowerCase()) .toList(); for (final list in collection.lists) { if (list.status != null) { if (list.status == oldStatus) { if (list.status == entry.listStatus) { if (hiddenFromStatusLists) { list.removeByMediaId(entry.mediaId); continue; } if (!list.setByMediaId(entry)) { list.insertSorted(entry, _sort); } continue; } list.removeByMediaId(entry.mediaId); continue; } if (list.status == entry.listStatus) { list.insertSorted(entry, _sort); } continue; } if (customLists.contains(list.name.toLowerCase())) { if (!list.setByMediaId(entry)) { list.insertSorted(entry, _sort); } continue; } list.removeByMediaId(entry.mediaId); } return _withRemovedEmptyLists(collection); } PreviewCollection _saveEntryInPreviewCollection( PreviewCollection collection, Entry entry, ListStatus? oldStatus, ListStatus? newStatus, ) { if (newStatus == .current || newStatus == .repeating) { if (oldStatus == .current || oldStatus == .repeating) { collection.list.setByMediaId(entry); return collection; } collection.list.insertSorted(entry, _sort); return collection; } collection.list.removeByMediaId(entry.mediaId); return collection; } FullCollection _withRemovedEmptyLists(FullCollection collection) { final lists = collection.lists; int index = collection.index; for (int i = 0; i < lists.length; i++) { if (lists[i].entries.isEmpty) { if (i <= index) index--; lists.removeAt(i--); } } return collection.withIndex(index); } void _updateState(Collection? Function(Collection) mutator) { if (!state.hasValue) return; final result = mutator(state.value!); if (result != null) state = AsyncValue.data(result); } } ================================================ FILE: lib/feature/collection/collection_top_bar.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/collection/collection_entries_provider.dart'; import 'package:otraku/feature/collection/collection_filter_provider.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/collection/collection_provider.dart'; import 'package:otraku/feature/collection/collection_filter_view.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/debounce.dart'; import 'package:otraku/widget/input/search_field.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/sheets.dart'; class CollectionTopBarTrailingContent extends StatelessWidget { const CollectionTopBarTrailingContent(this.tag, this.focusNode); final CollectionTag tag; final FocusNode? focusNode; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { final filter = ref.watch(collectionFilterProvider(tag)); final filterIcon = IconButton( tooltip: 'Filter', icon: const Icon(Ionicons.funnel_outline), onPressed: () => showSheet( context, CollectionFilterView( tag: tag, filter: filter.mediaFilter, onChanged: (mediaFilter) => ref .read(collectionFilterProvider(tag).notifier) .update((s) => s.copyWith(mediaFilter: mediaFilter)), ), ), ); return Expanded( child: Row( children: [ Expanded( child: SearchField( debounce: Debounce(), focusNode: focusNode, hint: ref.watch(collectionProvider(tag).select((s) => s.value?.listName ?? '')), value: filter.search, onChanged: (search) => ref .read(collectionFilterProvider(tag).notifier) .update((s) => s.copyWith(search: search)), ), ), IconButton( tooltip: 'Random', icon: const Icon(Ionicons.shuffle_outline), onPressed: () { final lists = ref.read(collectionEntriesProvider(tag)); if (lists.isEmpty) { ConfirmationDialog.show(context, title: 'No entries'); return; } final list = lists[Random().nextInt(lists.length)]; if (list.entries.isEmpty) { ConfirmationDialog.show(context, title: 'No entries'); return; } final entry = list.entries[Random().nextInt(list.entries.length)]; context.push(Routes.media(entry.mediaId, entry.imageUrl)); }, ), if (filter.mediaFilter.isActive) Badge( smallSize: 10, alignment: Alignment.topLeft, backgroundColor: ColorScheme.of(context).primary, child: filterIcon, ) else filterIcon, ], ), ); }, ); } } ================================================ FILE: lib/feature/collection/collection_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/collection/collection_floating_action.dart'; import 'package:otraku/feature/collection/collection_top_bar.dart'; import 'package:otraku/feature/discover/discover_filter_model.dart'; import 'package:otraku/feature/discover/discover_filter_provider.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/collection/collection_entries_provider.dart'; import 'package:otraku/feature/collection/collection_filter_provider.dart'; import 'package:otraku/feature/collection/collection_grid.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/collection/collection_provider.dart'; import 'package:otraku/widget/input/pill_selector.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/feature/collection/collection_list.dart'; class CollectionView extends StatefulWidget { const CollectionView(this.userId, this.ofAnime); final int userId; final bool ofAnime; @override State createState() => _CollectionViewState(); } class _CollectionViewState extends State { final _ctrl = ScrollController(); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final tag = (userId: widget.userId, ofAnime: widget.ofAnime); final formFactor = Theming.of(context).formFactor; return AdaptiveScaffold( topBar: TopBar(trailing: [CollectionTopBarTrailingContent(tag, null)]), floatingAction: formFactor == .phone ? HidingFloatingActionButton( key: const Key('lists'), scrollCtrl: _ctrl, child: CollectionFloatingAction(tag), ) : null, child: CollectionSubview(tag: tag, scrollCtrl: _ctrl, formFactor: formFactor), ); } } class CollectionSubview extends StatelessWidget { const CollectionSubview({ required this.tag, required this.scrollCtrl, required this.formFactor, super.key, }); final CollectionTag? tag; final ScrollController scrollCtrl; final FormFactor formFactor; @override Widget build(BuildContext context) { if (tag == null) { return const Center( child: Padding( padding: Theming.paddingAll, child: Text('Log in from the profile tab to view your collections', textAlign: .center), ), ); } return Consumer( builder: (context, ref, _) { ref.listen( collectionProvider(tag!), (_, s) => s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())), ); return ref .watch(collectionProvider(tag!)) .unwrapPrevious() .when( loading: () => const Center(child: Loader()), error: (_, _) => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ SliverRefreshControl(onRefresh: () => ref.invalidate(collectionProvider(tag!))), const SliverFillRemaining(child: Center(child: Text('Failed to load'))), ], ), data: (data) { final content = Scrollbar( controller: scrollCtrl, child: ConstrainedView( child: CustomScrollView( physics: Theming.bouncyPhysics, controller: scrollCtrl, slivers: [ SliverRefreshControl( onRefresh: () => ref.invalidate(collectionProvider(tag!)), ), _Content(tag!, data), const SliverFooter(), ], ), ), ); if (formFactor == .phone) return content; return switch (data) { PreviewCollection _ => content, FullCollection c => Row( children: [ PillSelector( maxWidth: 200, selected: c.index + 1, items: buildFullCollectionSelectionItems(context, data.lists), onTap: (i) => ref.read(collectionProvider(tag!).notifier).changeIndex(i - 1), ), Expanded(child: content), ], ), }; }, ); }, ); } } class _Content extends StatelessWidget { const _Content(this.tag, this.collection); final CollectionTag tag; final Collection collection; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { final lists = ref.watch(collectionEntriesProvider(tag)); final isViewer = ref.watch(viewerIdProvider) == tag.userId; if (lists.isEmpty) { if (!isViewer) { return const SliverFillRemaining(child: Center(child: Text('No results'))); } return SliverFillRemaining( child: Center( child: Column( mainAxisSize: .min, children: [ const Text('No results'), TextButton( onPressed: () => _searchGlobally(context, ref), child: const Text('Search Globally'), ), ], ), ), ); } final options = ref.watch(persistenceProvider.select((s) => s.options)); final onProgressUpdated = isViewer ? (oldEntry, setAsCurrent) => ref .read(collectionProvider(tag).notifier) .saveEntryProgress(oldEntry, setAsCurrent) : null; final (collectionIsExpanded, showAllLists) = switch (collection) { PreviewCollection _ => (false, false), FullCollection c => (true, c.index < 0), }; final useSimpleGrid = collectionIsExpanded && options.collectionItemView == .simple || !collectionIsExpanded && options.collectionPreviewItemView == .simple; if (!showAllLists) { return useSimpleGrid ? CollectionGrid( items: lists[0].entries, onProgressUpdated: onProgressUpdated, highContrast: options.highContrast, ) : CollectionList( items: lists[0].entries, onProgressUpdated: onProgressUpdated, scoreFormat: ref.watch( collectionProvider(tag).select((s) => s.value?.scoreFormat ?? .point10Decimal), ), highContrast: options.highContrast, ); } return SliverMainAxisGroup( slivers: [ for (final l in lists) ...[ SliverToBoxAdapter( child: Padding( padding: const .only(bottom: Theming.offset), child: Text(l.name, style: TextTheme.of(context).bodyLarge), ), ), useSimpleGrid ? CollectionGrid( items: l.entries, onProgressUpdated: onProgressUpdated, highContrast: options.highContrast, ) : CollectionList( items: l.entries, onProgressUpdated: onProgressUpdated, scoreFormat: ref.watch( collectionProvider( tag, ).select((s) => s.value?.scoreFormat ?? .point10Decimal), ), highContrast: options.highContrast, ), ], ], ); }, ); } void _searchGlobally(BuildContext context, WidgetRef ref) { final collectionFilter = ref.read(collectionFilterProvider(tag)); final sort = ref.read(persistenceProvider).discoverMediaFilter.sort; ref .read(discoverFilterProvider.notifier) .update( (f) => f.copyWith( type: tag.ofAnime ? .anime : .manga, search: collectionFilter.search, mediaFilter: DiscoverMediaFilter.fromCollection( filter: collectionFilter.mediaFilter, sort: sort, ofAnime: tag.ofAnime, ), ), ); context.go(Routes.home(.discover)); ref.invalidate(collectionFilterProvider(tag)); } } ================================================ FILE: lib/feature/comment/comment_model.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/util/markdown.dart'; class Comment { Comment._({ required this.id, required this.text, required this.likeCount, required this.isLiked, required this.isLocked, required this.createdAt, required this.siteUrl, required this.userId, required this.userName, required this.userAvatarUrl, required this.threadId, required this.threadTitle, required this.childComments, }); factory Comment(Map map) { final childComments = []; for (final c in map['childComments'] ?? const []) { childComments.add(Comment(c)); } return Comment._( id: map['id'], text: parseMarkdown(map['comment'] ?? ''), likeCount: map['likeCount'] ?? 0, isLiked: map['isLiked'] ?? false, isLocked: map['isLocked'] ?? false, createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']), siteUrl: map['siteUrl'] ?? '', userId: map['user']?['id'] ?? 0, userName: map['user']?['name'] ?? '?', userAvatarUrl: map['user']?['avatar']?['large'] ?? '', threadId: map['thread']?['id'] ?? 0, threadTitle: map['thread']?['title'] ?? '', childComments: childComments, ); } final int id; final String text; int likeCount; bool isLiked; final bool isLocked; final DateTime createdAt; final String siteUrl; final int userId; final String userName; final String userAvatarUrl; final int threadId; final String threadTitle; final List childComments; Comment _copyWith({String? text, List? childComments}) => Comment._( id: id, text: text ?? this.text, likeCount: likeCount, isLiked: isLiked, isLocked: isLocked, createdAt: createdAt, siteUrl: siteUrl, userId: userId, userName: userName, userAvatarUrl: userAvatarUrl, threadId: threadId, threadTitle: threadTitle, childComments: childComments ?? this.childComments, ); Comment withEditedText(String text) => _copyWith(text: text); Comment withAppendedChildComment(Map map, int parentCommentId) { if (id == parentCommentId) { return _copyWith(childComments: [...childComments, Comment(map)]); } for (final comment in childComments) { if (comment.append(map, parentCommentId)) { return _copyWith(childComments: [...childComments]); } } return this; } bool append(Map map, int parentCommentId) { for (final comment in childComments) { if (comment.id == parentCommentId) { comment.childComments.add(Comment(map)); return true; } if (comment.append(map, parentCommentId)) { return true; } } return false; } } ================================================ FILE: lib/feature/comment/comment_provider.dart ================================================ import 'dart:async'; import 'dart:collection'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/feature/comment/comment_model.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final commentProvider = AsyncNotifierProvider.autoDispose.family( CommentNotifier.new, ); class CommentNotifier extends AsyncNotifier { CommentNotifier(this.arg); final int arg; @override FutureOr build() async { final data = await ref.read(repositoryProvider).request(GqlQuery.comment, {'id': arg}); // The response is a list of comments that match the filter criteria. // Since we're filtering by id, we expect exactly one comment. final comments = data['ThreadComment']; if (comments.isEmpty) { throw Exception('Not Found'); } // The response always starts from the root comment, // even if a subcomment was requested. // We search for the requested subcomment with BFS. final queue = Queue>(); queue.add(comments[0]); while (queue.isNotEmpty) { final comment = queue.removeFirst(); if (comment['id'] == arg) { return Comment(comment); } for (final child in comment['childComments'] ?? const []) { queue.addLast(child); } } throw Exception('Not Found'); } void edit(Map map) => state = state.whenData((data) => data.withEditedText(map['comment'])); Future toggleCommentLike(int commentId) { return ref.read(repositoryProvider).request(GqlMutation.toggleLike, { 'id': commentId, 'type': 'THREAD_COMMENT', }).getErrorOrNull(); } void appendComment(Map map, int parentCommentId) { final value = state.value; if (value == null) return; state = AsyncValue.data(value.withAppendedChildComment(map, parentCommentId)); } Future delete() => ref.read(repositoryProvider).request(GqlMutation.deleteComment, {'id': arg}).getErrorOrNull(); } ================================================ FILE: lib/feature/comment/comment_tile.dart ================================================ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/composition/composition_model.dart'; import 'package:otraku/feature/composition/composition_view.dart'; import 'package:otraku/feature/comment/comment_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/widget/timestamp.dart'; const _maxCommentDepth = 6; typedef CommentTileInteraction = ({ void Function(Map map, int commentId) onReplySaved, Future Function(int commentId) toggleLike, }); class CommentTile extends StatelessWidget { const CommentTile( this.comment, { required this.viewerId, required this.highContrast, required this.analogClock, this.interaction, this.depth = 0, }); final Comment comment; final CommentTileInteraction? interaction; final int? viewerId; final bool highContrast; final bool analogClock; final int depth; @override Widget build(BuildContext context) { final userRow = Row( spacing: Theming.offset, children: [ GestureDetector( onTap: () => context.push(Routes.user(comment.userId, comment.userAvatarUrl)), child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage(comment.userAvatarUrl, height: 50, width: 50), ), ), Expanded( child: OverflowBar( spacing: 5, overflowSpacing: 5, children: [ Text(comment.userName, overflow: .ellipsis, maxLines: 1), Timestamp( comment.createdAt, analogClock, leading: Text('replied', style: TextTheme.of(context).labelSmall), ), ], ), ), ], ); final contentColumn = Padding( padding: const .only(left: Theming.offset, top: Theming.offset), child: Column( mainAxisSize: .min, crossAxisAlignment: .start, children: [ Padding(padding: const .only(right: 10, bottom: 5), child: HtmlContent(comment.text)), Padding( padding: const .only(right: 10, bottom: 10), child: Row( spacing: Theming.offset, children: [ if (comment.isLocked) Tooltip( message: 'Locked', triggerMode: .tap, child: Icon(Icons.lock_outline_rounded, size: Theming.iconSmall), ), const Spacer(), if (interaction != null) ...[ if (comment.userId != viewerId) Tooltip( message: 'Reply', child: InkResponse( radius: Theming.radiusSmall.x, onTap: () => showSheet( context, CompositionView( tag: CommentCompositionTag( threadId: comment.threadId, parentCommentId: comment.id, ), onSaved: (map) => interaction!.onReplySaved(map, comment.id), ), ), child: Row( spacing: 5, children: [ Text( comment.childComments.length.toString(), style: TextTheme.of(context).labelSmall, ), const Icon(Icons.reply_all_rounded, size: Theming.iconSmall), ], ), ), ) else Tooltip( message: 'Replies', child: InkResponse( radius: Theming.radiusSmall.x, onTap: () => context.push(Routes.comment(comment.id)), child: Row( spacing: 5, children: [ Text( comment.childComments.length.toString(), style: TextTheme.of(context).labelSmall, ), const Icon(Icons.reply_all_rounded, size: Theming.iconSmall), ], ), ), ), _LikeButton(comment, interaction!.toggleLike), ] else ...[ SizedBox( height: 20, child: Tooltip( message: 'Replies', child: InkResponse( radius: Theming.radiusSmall.x, onTap: () => context.push(Routes.comment(comment.id)), child: const Icon(Icons.reply_all_rounded, size: Theming.iconSmall), ), ), ), Tooltip( message: 'Likes', triggerMode: .tap, child: Row( mainAxisSize: .min, children: [ Text( comment.likeCount.toString(), style: Theme.of(context).textTheme.labelSmall, ), const SizedBox(width: 5), Icon(Icons.favorite_outline_rounded, size: Theming.iconSmall), ], ), ), ], ], ), ), if (comment.childComments.isNotEmpty) depth < _maxCommentDepth ? Column( spacing: Theming.offset, mainAxisSize: .min, children: comment.childComments .map( (c) => CommentTile( c, viewerId: viewerId, highContrast: highContrast, analogClock: analogClock, interaction: interaction, depth: depth + 1, ), ) .toList(), ) : TextButton( onPressed: () => context.push(Routes.comment(comment.id)), child: Text( comment.childComments.length > 1 ? '${comment.childComments.length} replies' : '1 reply', ), ), ], ), ); return Column( spacing: Theming.offset, mainAxisSize: .min, crossAxisAlignment: .start, children: [ userRow, if (highContrast) Card.outlined(child: contentColumn) else Card( color: depth % 2 == 0 ? null : ColorScheme.of(context).surfaceContainerHigh, child: contentColumn, ), ], ); } } class _LikeButton extends StatefulWidget { const _LikeButton(this.comment, this.toggleLike); final Comment comment; final Future Function(int commentId) toggleLike; @override State<_LikeButton> createState() => __LikeButtonState(); } class __LikeButtonState extends State<_LikeButton> { @override Widget build(BuildContext context) { final comment = widget.comment; return Tooltip( message: !comment.isLiked ? 'Like' : 'Unlike', child: InkResponse( radius: Theming.radiusSmall.x, onTap: () async { final prevIsLiked = comment.isLiked; final prevLikeCount = comment.likeCount; setState(() { comment.isLiked = !prevIsLiked; comment.likeCount = prevLikeCount + 1; }); final err = await widget.toggleLike(comment.id); if (err == null) return; setState(() { comment.isLiked = prevIsLiked; comment.likeCount = prevLikeCount; }); if (context.mounted) { SnackBarExtension.show(context, err.toString()); } }, child: Row( children: [ Text( comment.likeCount.toString(), style: !comment.isLiked ? TextTheme.of(context).labelSmall : TextTheme.of( context, ).labelSmall!.copyWith(color: ColorScheme.of(context).primary), ), const SizedBox(width: 5), Icon( !comment.isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded, size: Theming.iconSmall, color: comment.isLiked ? ColorScheme.of(context).primary : null, ), ], ), ), ); } } ================================================ FILE: lib/feature/comment/comment_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/comment/comment_model.dart'; import 'package:otraku/feature/comment/comment_provider.dart'; import 'package:otraku/feature/comment/comment_tile.dart'; import 'package:otraku/feature/composition/composition_model.dart'; import 'package:otraku/feature/composition/composition_view.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/sheets.dart'; class CommentView extends ConsumerStatefulWidget { const CommentView(this.id); final int id; @override ConsumerState createState() => _CommentViewState(); } class _CommentViewState extends ConsumerState { final _scrollCtrl = ScrollController(); @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { ref.listen( commentProvider(widget.id), (_, s) => s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())), ); final comment = ref.watch(commentProvider(widget.id)); final viewerId = ref.watch(viewerIdProvider); final options = ref.watch(persistenceProvider.select((s) => s.options)); TopBar? topBar; void Function()? floatingActionOnPressed; if (comment.hasValue) { final value = comment.value!; topBar = TopBar(trailing: _topBarTrailingContent(context, ref, value, viewerId)); floatingActionOnPressed = () => showSheet( context, CompositionView( tag: CommentCompositionTag(threadId: value.threadId, parentCommentId: value.id), onSaved: (map) => ref.read(commentProvider(widget.id).notifier).appendComment(map, value.id), ), ); } return AdaptiveScaffold( topBar: topBar ?? const TopBar(), floatingAction: HidingFloatingActionButton( key: const Key('Reply'), scrollCtrl: _scrollCtrl, child: FloatingActionButton( tooltip: 'New Reply', onPressed: floatingActionOnPressed, child: const Icon(Icons.edit_outlined), ), ), child: ConstrainedView( child: switch (comment.unwrapPrevious()) { AsyncData(:final value) => _Content( ref, value, options.highContrast, options.analogClock, ), AsyncError() => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ SliverRefreshControl(onRefresh: () => ref.invalidate(commentProvider(widget.id))), const SliverFillRemaining(child: Center(child: Text('Failed to load'))), ], ), AsyncLoading() => const Center(child: Loader()), }, ), ); } List _topBarTrailingContent( BuildContext context, WidgetRef ref, Comment comment, int? viewerId, ) => [ const Spacer(), IconButton( tooltip: 'More', icon: const Icon(Ionicons.ellipsis_horizontal), onPressed: () => showSheet( context, SimpleSheet.link( context, comment.siteUrl, viewerId == comment.userId ? [ ListTile( title: const Text('Edit'), leading: const Icon(Icons.edit_outlined), onTap: () => showSheet( context, CompositionView( tag: CommentCompositionTag.edit(id: comment.id, threadId: comment.threadId), onSaved: (map) { ref.read(commentProvider(widget.id).notifier).edit(map); Navigator.pop(context); }, ), ), ), ListTile( title: const Text('Delete'), leading: const Icon(Ionicons.trash_outline), onTap: () { Navigator.pop(context); ConfirmationDialog.show( context, title: 'Delete?', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: () async { final err = await ref.read(commentProvider(widget.id).notifier).delete(); if (!context.mounted) return; if (err == null) { Navigator.pop(context); return; } SnackBarExtension.show(context, 'Failed deleting comment: $err'); }, ); }, ), ] : const [], ), ), ), ]; } class _Content extends StatelessWidget { const _Content(this.ref, this.comment, this.highContrast, this.analogClock); final WidgetRef ref; final Comment comment; final bool highContrast; final bool analogClock; @override Widget build(BuildContext context) { final openThread = () => context.push(Routes.thread(comment.threadId)); return CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ SliverRefreshControl(onRefresh: () => ref.invalidate(commentProvider(comment.id))), SliverToBoxAdapter( child: Semantics( onTap: openThread, onTapHint: 'open thread', child: GestureDetector( onTap: openThread, behavior: .opaque, child: Text(comment.threadTitle, style: TextTheme.of(context).bodyMedium), ), ), ), SliverToBoxAdapter(child: SizedBox(height: Theming.offset)), SliverToBoxAdapter( child: CommentTile( comment, viewerId: ref.watch(viewerIdProvider), highContrast: highContrast, analogClock: analogClock, interaction: ( onReplySaved: (map, commentId) => ref.read(commentProvider(comment.id).notifier).appendComment(map, commentId), toggleLike: (commentId) => ref.read(commentProvider(comment.id).notifier).toggleCommentLike(commentId), ), ), ), const SliverFooter(), ], ); } } ================================================ FILE: lib/feature/composition/composition_model.dart ================================================ /// Each type of composition is represented by a different tag class that /// extends [CompositionTag]. All tags must implement equals and hash for /// riverpod to work correctly. sealed class CompositionTag { const CompositionTag({required this.id}); final int? id; } class StatusActivityCompositionTag extends CompositionTag { const StatusActivityCompositionTag({required super.id}); @override bool operator ==(Object other) => other is StatusActivityCompositionTag && id == other.id; @override int get hashCode => id.hashCode; } class MessageActivityCompositionTag extends CompositionTag { const MessageActivityCompositionTag({required super.id, required this.recipientId}); final int recipientId; @override bool operator ==(Object other) => other is MessageActivityCompositionTag && id == other.id && recipientId == other.recipientId; @override int get hashCode => Object.hash(id, recipientId); } class ActivityReplyCompositionTag extends CompositionTag { const ActivityReplyCompositionTag({required super.id, required this.activityId}); final int activityId; @override bool operator ==(Object other) => other is ActivityReplyCompositionTag && id == other.id && activityId == other.activityId; @override int get hashCode => Object.hash(id, activityId); } class CommentCompositionTag extends CompositionTag { const CommentCompositionTag({required this.threadId, required this.parentCommentId}) : super(id: null); const CommentCompositionTag.edit({required super.id, required this.threadId}) : parentCommentId = null; final int threadId; final int? parentCommentId; @override bool operator ==(Object other) => other is CommentCompositionTag && id == other.id && threadId == other.threadId && parentCommentId == other.parentCommentId; @override int get hashCode => Object.hash(id, threadId, parentCommentId); } class Composition { Composition(this.text); String text; } /// Only used for new message activities, since the user can toggle visibility. class PrivateComposition extends Composition { PrivateComposition(super.text, this.isPrivate); bool isPrivate; } ================================================ FILE: lib/feature/composition/composition_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/util/graphql.dart'; import 'package:otraku/feature/composition/composition_model.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; final compositionProvider = AsyncNotifierProvider.autoDispose .family(CompositionNotifier.new); class CompositionNotifier extends AsyncNotifier { CompositionNotifier(this.arg); final CompositionTag arg; @override FutureOr build() { if (arg.id == null) { return switch (arg) { MessageActivityCompositionTag _ => PrivateComposition('', false), _ => Composition(''), }; } return switch (arg) { StatusActivityCompositionTag(id: var id) => ref .read(repositoryProvider) .request(GqlQuery.activityComposition, {'id': id}) .then((data) => Composition(data['Activity']['text'])), MessageActivityCompositionTag(id: var id) => ref .read(repositoryProvider) .request(GqlQuery.activityComposition, {'id': id}) .then((data) => Composition(data['Activity']['message'])), ActivityReplyCompositionTag(id: var id) => ref .read(repositoryProvider) .request(GqlQuery.activityReplyComposition, {'id': id}) .then((data) => Composition(data['ActivityReply']['text'])), CommentCompositionTag(id: var id) => ref .read(repositoryProvider) .request(GqlQuery.commentComposition, {'id': id}) .then((data) => Composition(_findComment(data['ThreadComment'][0]))), }; } /// The API always returns the root comment, /// so we search for the target comment with DFS. String _findComment(Map map) { if (map['id'] == arg.id) { return map['comment'] ?? ''; } for (final c in map['childComments'] ?? const []) { final comment = _findComment(c); if (comment != '') return comment; } return ''; } Future>> save() async { final value = state.value; if (value == null) return const AsyncValue.loading(); return AsyncValue.guard(() async { switch (arg) { case StatusActivityCompositionTag(id: var id): final data = await ref.read(repositoryProvider).request(GqlMutation.saveStatusActivity, { 'id': ?id, 'text': value.text.withParsedEmojis, }); return data['SaveTextActivity']; case MessageActivityCompositionTag(id: var id, recipientId: var rcpId): final data = await ref.read(repositoryProvider).request(GqlMutation.saveMessageActivity, { 'id': ?id, 'text': value.text.withParsedEmojis, 'recipientId': rcpId, if (value is PrivateComposition) 'isPrivate': value.isPrivate, }); return data['SaveMessageActivity']; case ActivityReplyCompositionTag(id: var id, activityId: var actId): final data = await ref.read(repositoryProvider).request(GqlMutation.saveActivityReply, { 'id': ?id, 'text': value.text.withParsedEmojis, 'activityId': actId, }); return data['SaveActivityReply']; case CommentCompositionTag( id: var id, threadId: var threadId, parentCommentId: var parentCommentId, ): final data = await ref.read(repositoryProvider).request(GqlMutation.saveComment, { 'id': ?id, 'text': value.text.withParsedEmojis, 'threadId': threadId, 'parentCommentId': ?parentCommentId, }); return data['SaveThreadComment']; } }); } } ================================================ FILE: lib/feature/composition/composition_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/util/markdown.dart'; import 'package:otraku/feature/composition/composition_model.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:otraku/widget/layout/navigation_tool.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/composition/composition_provider.dart'; class CompositionView extends StatelessWidget { const CompositionView({required this.tag, required this.onSaved, this.defaultText = ''}); final CompositionTag tag; final String defaultText; /// When the edit is saved, a map with the new data is passed back /// to get deserialized. final void Function(Map) onSaved; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { return ref .watch(compositionProvider(tag)) .when( loading: () => SheetWithButtonRow( builder: (context, scrollCtrl) => const Center(child: Loader()), ), error: (_, _) => SheetWithButtonRow( builder: (context, scrollCtrl) => const Center(child: Text('Failed Loading')), ), data: (data) { if (data.text.isEmpty) { data.text = defaultText; } return _CompositionView( composition: data, trySave: () async { final result = await ref.read(compositionProvider(tag).notifier).save(); return result.maybeWhen( data: (data) { onSaved(result.value!); Navigator.pop(context); return true; }, orElse: () => false, ); }, ); }, ); }, ); } } class _CompositionView extends StatefulWidget { const _CompositionView({required this.composition, required this.trySave}); final Composition composition; final Future Function() trySave; @override State<_CompositionView> createState() => __CompositionViewState(); } class __CompositionViewState extends State<_CompositionView> with SingleTickerProviderStateMixin { late final _textCtrl = TextEditingController(text: widget.composition.text); late final _tabCtrl = TabController(length: 2, vsync: this); String _parsedText = ''; final _focus = FocusNode(); @override void initState() { super.initState(); _tabCtrl.addListener(() { setState(() {}); if (_tabCtrl.index == 0) { _focus.requestFocus(); } else { _focus.unfocus(); _parsedText = parseMarkdown(_textCtrl.text); } }); } @override void dispose() { _tabCtrl.dispose(); _textCtrl.dispose(); _focus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SheetWithButtonRow( builder: (context, scrollCtrl) => _CompositionBody( focus: _focus, tabCtrl: _tabCtrl, textCtrl: _textCtrl, scrollCtrl: scrollCtrl, parsedText: _parsedText, ), buttons: _BottomBar( composition: widget.composition, textCtrl: _textCtrl, isEditing: _tabCtrl.index == 0, trySave: widget.trySave, ), ); } } class _CompositionBody extends StatelessWidget { const _CompositionBody({ required this.focus, required this.tabCtrl, required this.textCtrl, required this.scrollCtrl, required this.parsedText, }); final FocusNode focus; final TabController tabCtrl; final TextEditingController textCtrl; final ScrollController scrollCtrl; final String parsedText; @override Widget build(BuildContext context) { final padding = EdgeInsets.only( left: 20, right: 20, top: 60, bottom: MediaQuery.paddingOf(context).bottom + Theming.offset, ); return Stack( children: [ TabBarView( controller: tabCtrl, children: [ SingleChildScrollView( controller: scrollCtrl, child: TextField( autofocus: true, focusNode: focus, controller: textCtrl, style: TextTheme.of(context).bodyMedium, decoration: InputDecoration(contentPadding: padding), maxLines: null, ), ), SingleChildScrollView( controller: scrollCtrl, child: Padding(padding: padding, child: HtmlContent(parsedText)), ), ], ), Positioned( top: 0, left: 0, right: 0, child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Theming.radiusBig), child: BackdropFilter( filter: Theming.blurFilter, child: Container( padding: Theming.paddingAll, color: Theme.of(context).navigationBarTheme.backgroundColor, child: SegmentedButton( segments: const [ ButtonSegment( value: 0, label: Text('Compose'), icon: Icon(Icons.edit_outlined), ), ButtonSegment( value: 1, label: Text('Preview'), icon: Icon(Icons.preview_outlined), ), ], selected: {tabCtrl.index}, onSelectionChanged: (i) => tabCtrl.index = i.first, ), ), ), ), ), ], ); } } /// A button menu. Some of the buttons are hidden, /// when the user isn't on the editing tab. class _BottomBar extends StatefulWidget { const _BottomBar({ required this.composition, required this.isEditing, required this.textCtrl, required this.trySave, }); final Composition composition; final bool isEditing; final TextEditingController textCtrl; final Future Function() trySave; @override State<_BottomBar> createState() => _BottomBarState(); } class _BottomBarState extends State<_BottomBar> { bool _locked = false; @override Widget build(BuildContext context) { return BottomBar([ if (widget.isEditing) ...[ Expanded( child: ListView( scrollDirection: Axis.horizontal, children: [ _FormatButton( startDelimiter: '**', endDelimiter: '**', name: 'Bold', icon: Icons.format_bold_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: '*', endDelimiter: '*', name: 'Italic', icon: Icons.format_italic_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: '~~', endDelimiter: '~~', name: 'Strikethrough', icon: Icons.format_strikethrough_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: '~!', endDelimiter: '!~', name: 'Spoiler', icon: Icons.hide_image_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: '[', endDelimiter: ']()', name: 'Link', icon: Icons.link_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: 'img(', endDelimiter: ')', name: 'Image', icon: Icons.image_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: 'youtube(', endDelimiter: ')', name: 'YouTube Video', icon: Icons.video_collection_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: 'webm(', endDelimiter: ')', name: 'WebM Video', icon: Icons.videocam_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: '~~~', endDelimiter: '~~~', name: 'Center', icon: Icons.align_horizontal_center_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: '# ', endDelimiter: '', name: 'Header', icon: Icons.title_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: '> ', endDelimiter: '', name: 'Quote', icon: Icons.format_quote_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: '`', endDelimiter: '`', name: 'Code', icon: Icons.code_outlined, textCtrl: widget.textCtrl, ), _FormatButton( startDelimiter: '```', endDelimiter: '```', name: 'Code Block', icon: Icons.code_off_outlined, textCtrl: widget.textCtrl, ), ], ), ), Container( width: 3, height: 40, decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), color: ColorScheme.of(context).outline, ), ), ] else const Spacer(), if (widget.composition is PrivateComposition) _PrivateButton(widget.composition as PrivateComposition), IconButton( tooltip: 'Post', icon: const Icon(Ionicons.send_outline), onPressed: _locked ? null : () async { setState(() => _locked = true); widget.composition.text = widget.textCtrl.text; if (await widget.trySave()) return; setState(() => _locked = false); if (context.mounted) { SnackBarExtension.show(context, 'Failed to save'); } }, ), ]); } } /// Encloses the current text selection in a given markdown tag. class _FormatButton extends StatelessWidget { const _FormatButton({ required this.startDelimiter, required this.endDelimiter, required this.name, required this.icon, required this.textCtrl, }); final String startDelimiter; final String endDelimiter; final String name; final IconData icon; final TextEditingController textCtrl; @override Widget build(BuildContext context) => IconButton( tooltip: name, icon: Icon(icon), onPressed: () { final txt = textCtrl.text; final beg = textCtrl.selection.start; final end = textCtrl.selection.end; if (beg < 0) return; final text = '${txt.substring(0, beg)}' '$startDelimiter' '${txt.substring(beg, end)}' '$endDelimiter' '${txt.substring(end)}'; final offset = textCtrl.selection.isCollapsed ? textCtrl.selection.end + startDelimiter.length : textCtrl.selection.end + startDelimiter.length + endDelimiter.length; textCtrl.value = TextEditingValue( text: text, selection: TextSelection.collapsed(offset: offset), ); }, ); } /// Controls whether a message will be created as private or public. class _PrivateButton extends StatefulWidget { const _PrivateButton(this.composition); final PrivateComposition composition; @override State<_PrivateButton> createState() => __PrivateButtonState(); } class __PrivateButtonState extends State<_PrivateButton> { @override Widget build(BuildContext context) => IconButton( tooltip: widget.composition.isPrivate ? 'Make Public' : 'Make Private', icon: widget.composition.isPrivate ? const Icon(Ionicons.eye_outline) : const Icon(Ionicons.eye_off_outline), onPressed: () { setState(() => widget.composition.isPrivate = !widget.composition.isPrivate); SnackBarExtension.show( context, widget.composition.isPrivate ? 'Message is now private' : 'Message is now public', ); }, ); } ================================================ FILE: lib/feature/discover/discover_filter_model.dart ================================================ import 'package:otraku/extension/enum_extension.dart'; import 'package:otraku/feature/collection/collection_filter_model.dart'; import 'package:otraku/feature/discover/discover_model.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/review/review_models.dart'; class DiscoverFilter { const DiscoverFilter._({ required this.type, required this.search, required this.mediaFilter, required this.hasBirthday, required this.reviewsFilter, required this.recommendationsFilter, }); DiscoverFilter(this.type, this.mediaFilter) : search = '', hasBirthday = false, reviewsFilter = const ReviewsFilter(), recommendationsFilter = const DiscoverRecommendationsFilter(); final DiscoverType type; final String search; final DiscoverMediaFilter mediaFilter; final bool hasBirthday; final ReviewsFilter reviewsFilter; final DiscoverRecommendationsFilter recommendationsFilter; DiscoverFilter copyWith({ DiscoverType? type, String? search, DiscoverMediaFilter? mediaFilter, bool? hasBirthday, ReviewsFilter? reviewsFilter, DiscoverRecommendationsFilter? recommendationsFilter, }) => DiscoverFilter._( type: type ?? this.type, search: search ?? this.search, mediaFilter: mediaFilter ?? this.mediaFilter, hasBirthday: hasBirthday ?? this.hasBirthday, reviewsFilter: reviewsFilter ?? this.reviewsFilter, recommendationsFilter: recommendationsFilter ?? this.recommendationsFilter, ); } class DiscoverMediaFilter { DiscoverMediaFilter(this.sort); factory DiscoverMediaFilter.fromPersistenceMap(Map map) { final sort = MediaSort.values.getOrFirst(map['sort']); final filter = DiscoverMediaFilter(sort) ..season = MediaSeason.values.getOrNull(map['season']) ..startYearFrom = map['startYearFrom'] ..startYearTo = map['startYearTo'] ..country = OriginCountry.values.getOrNull(map['country']) ..inLists = map['inLists'] ..isAdult = map['isAdult'] ..isLicensed = map['isLicensed']; for (final e in map['statuses'] ?? const []) { final status = ReleaseStatus.values.getOrNull(e); if (status != null) { filter.statuses.add(status); } } for (final e in map['animeFormats'] ?? const []) { final format = MediaFormat.values.getOrNull(e); if (format != null) { filter.animeFormats.add(format); } } for (final e in map['mangaFormats'] ?? const []) { final format = MediaFormat.values.getOrNull(e); if (format != null) { filter.mangaFormats.add(format); } } for (final e in map['sources'] ?? const []) { final source = MediaSource.values.getOrNull(e); if (source != null) { filter.sources.add(source); } } filter.genreIn.addAll(map['genreIn'] ?? const []); filter.genreNotIn.addAll(map['genreNotIn'] ?? const []); filter.tagIn.addAll(map['tagIn'] ?? const []); filter.tagNotIn.addAll(map['tagNotIn'] ?? const []); return filter; } final statuses = []; final animeFormats = []; final mangaFormats = []; final genreIn = []; final genreNotIn = []; final tagIn = []; final tagNotIn = []; final sources = []; MediaSort sort; MediaSeason? season; int? startYearFrom; int? startYearTo; OriginCountry? country; bool? inLists; bool? isAdult; bool? isLicensed; bool get isActive => statuses.isNotEmpty || animeFormats.isNotEmpty || mangaFormats.isNotEmpty || genreIn.isNotEmpty || genreNotIn.isNotEmpty || tagIn.isNotEmpty || tagNotIn.isNotEmpty || sources.isNotEmpty || season != null || startYearFrom != null || startYearTo != null || country != null || inLists != null || isAdult != null || isLicensed != null; DiscoverMediaFilter copy() => DiscoverMediaFilter(sort) ..statuses.addAll(statuses) ..animeFormats.addAll(animeFormats) ..mangaFormats.addAll(mangaFormats) ..genreIn.addAll(genreIn) ..genreNotIn.addAll(genreNotIn) ..tagIn.addAll(tagIn) ..tagNotIn.addAll(tagNotIn) ..sources.addAll(sources) ..season = season ..startYearFrom = startYearFrom ..startYearTo = startYearTo ..country = country ..inLists = inLists ..isAdult = isAdult ..isLicensed = isLicensed; static DiscoverMediaFilter fromCollection({ required CollectionMediaFilter filter, required MediaSort sort, required bool ofAnime, }) => DiscoverMediaFilter(sort) ..statuses.addAll(filter.statuses) ..animeFormats.addAll(ofAnime ? filter.formats : const []) ..mangaFormats.addAll(!ofAnime ? filter.formats : const []) ..genreIn.addAll(filter.genreIn) ..genreNotIn.addAll(filter.genreNotIn) ..tagIn.addAll(filter.tagIn) ..tagNotIn.addAll(filter.tagNotIn) ..startYearFrom = filter.startYearFrom ..startYearTo = filter.startYearTo ..country = filter.country; Map toGraphQlVariables({required bool ofAnime}) => { 'sort': sort.value, if (ofAnime && animeFormats.isNotEmpty) 'format_in': animeFormats.map((v) => v.value).toList(), if (!ofAnime && mangaFormats.isNotEmpty) 'format_in': mangaFormats.map((v) => v.value).toList(), if (statuses.isNotEmpty) 'status_in': statuses.map((v) => v.value).toList(), if (sources.isNotEmpty) 'sources': sources.map((v) => v.value).toList(), if (ofAnime && season != null) 'season': season!.value, if (genreIn.isNotEmpty) 'genre_in': genreIn, if (genreNotIn.isNotEmpty) 'genre_not_in': genreNotIn, if (tagIn.isNotEmpty) 'tag_in': tagIn, if (tagNotIn.isNotEmpty) 'tag_not_in': tagNotIn, if (startYearFrom != null) 'startFrom': '${startYearFrom! - 1}9999', if (startYearTo != null) 'startTo': '${startYearTo! + 1}0000', if (country != null) 'countryOfOrigin': country!.code, if (inLists != null) 'onList': inLists, if (isAdult != null) 'isAdult': isAdult, if (isLicensed != null) 'isLicensed': isLicensed, }; Map toPersistenceMap() => { 'statuses': statuses.map((e) => e.index).toList(), 'animeFormats': animeFormats.map((e) => e.index).toList(), 'mangaFormats': mangaFormats.map((e) => e.index).toList(), 'genreIn': genreIn, 'genreNotIn': genreNotIn, 'tagIn': tagIn, 'tagNotIn': tagNotIn, 'sources': sources.map((e) => e.index).toList(), 'sort': sort.index, 'season': season?.index, 'startYearFrom': startYearFrom, 'startYearTo': startYearTo, 'country': country?.index, 'inLists': inLists, 'isAdult': isAdult, 'isLicensed': isLicensed, }; } class DiscoverRecommendationsFilter { const DiscoverRecommendationsFilter({this.sort = .recent, this.inLists}); final RecommendationsSort sort; final bool? inLists; DiscoverRecommendationsFilter copyWith({RecommendationsSort? sort, (bool?,)? inLists}) => DiscoverRecommendationsFilter( sort: sort ?? this.sort, inLists: inLists == null ? this.inLists : inLists.$1, ); } enum RecommendationsSort { recent('ID_DESC'), highestRated('RATING_DESC'), lowestRated('RATING'); const RecommendationsSort(this.value); final String value; } ================================================ FILE: lib/feature/discover/discover_filter_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/discover/discover_filter_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; final discoverFilterProvider = NotifierProvider.autoDispose( DiscoverFilterNotifier.new, ); class DiscoverFilterNotifier extends Notifier { @override DiscoverFilter build() { final mediaFilter = ref.watch(persistenceProvider.select((s) => s.discoverMediaFilter)); final discoverType = ref.watch(persistenceProvider.select((s) => s.options.discoverType)); return DiscoverFilter(discoverType, mediaFilter); } @override DiscoverFilter get state => super.state; @override set state(DiscoverFilter newState) => super.state = newState; DiscoverFilter update(DiscoverFilter Function(DiscoverFilter) callback) => super.state = callback(state); } ================================================ FILE: lib/feature/discover/discover_floating_action.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/discover/discover_filter_provider.dart'; import 'package:otraku/feature/discover/discover_model.dart'; import 'package:otraku/widget/input/pill_selector.dart'; import 'package:otraku/widget/swipe_switcher.dart'; import 'package:otraku/widget/sheets.dart'; class DiscoverFloatingAction extends StatelessWidget { const DiscoverFloatingAction() : super(key: const Key('switchDiscover')); @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, child) { final type = ref.watch(discoverFilterProvider.select((s) => s.type)); return FloatingActionButton( tooltip: 'Types', onPressed: () { showSheet( context, SimpleSheet( initialHeight: PillSelector.expectedMinHeight(DiscoverType.values.length), builder: (context, scrollCtrl) => PillSelector( scrollCtrl: scrollCtrl, selected: type.index, items: DiscoverType.values.map((v) => Text(v.label)).toList(), onTap: (i) { ref .read(discoverFilterProvider.notifier) .update((s) => s.copyWith(type: DiscoverType.values[i])); Navigator.pop(context); }, ), ), ); }, child: SwipeSwitcher( index: type.index, onChanged: (index) => ref .read(discoverFilterProvider.notifier) .update((s) => s.copyWith(type: DiscoverType.values[index])), children: DiscoverType.values.map((v) => Icon(_typeIcon(v))).toList(), ), ); }, ); } static IconData _typeIcon(DiscoverType type) => switch (type) { .anime => Ionicons.film_outline, .manga => Ionicons.book_outline, .character => Ionicons.man_outline, .staff => Ionicons.mic_outline, .studio => Ionicons.business_outline, .user => Ionicons.person_outline, .review => Icons.rate_review_outlined, .recommendation => Icons.thumb_up_outlined, }; } ================================================ FILE: lib/feature/discover/discover_media_filter_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/discover/discover_filter_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/feature/tag/tag_picker.dart'; import 'package:otraku/widget/input/year_range_picker.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/tag/tag_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/navigation_tool.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/sheets.dart'; class DiscoverMediaFilterView extends ConsumerStatefulWidget { const DiscoverMediaFilterView({ required this.ofAnime, required this.filter, required this.onChanged, }); final bool ofAnime; final DiscoverMediaFilter filter; final void Function(DiscoverMediaFilter) onChanged; @override ConsumerState createState() => _DiscoverFilterViewState(); } class _DiscoverFilterViewState extends ConsumerState { late final _filter = widget.filter.copy(); @override Widget build(BuildContext context) { final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast)); final applyButton = BottomBarButton( text: 'Apply', icon: Icons.done_rounded, onTap: () { widget.onChanged(_filter); Navigator.pop(context); }, ); final revertToDefaultButton = BottomBarButton( text: 'Reset', icon: Icons.restore_rounded, foregroundColor: ColorScheme.of(context).secondary, onTap: () { widget.onChanged(ref.read(persistenceProvider).discoverMediaFilter); Navigator.pop(context); }, ); final saveButton = BottomBarButton( text: 'Save', icon: Icons.save_outlined, foregroundColor: ColorScheme.of(context).secondary, onTap: () => ConfirmationDialog.show( context, title: 'Make default?', content: 'The current filters and sorting will become the default.', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: () { ref.read(persistenceProvider.notifier).setDiscoverMediaFilter(_filter); widget.onChanged(_filter); Navigator.pop(context); }, ), ); return SheetWithButtonRow( buttons: BottomBar( Theming.of(context).rightButtonOrientation ? [saveButton, revertToDefaultButton, applyButton] : [applyButton, revertToDefaultButton, saveButton], ), builder: (context, scrollCtrl) => Padding( padding: const .symmetric(horizontal: Theming.offset), child: ListView( controller: scrollCtrl, padding: const .only(top: 20), children: [ ChipSelector.ensureSelected( title: 'Sorting', items: MediaSort.values.map((v) => (v.label, v)).toList(), value: _filter.sort, onChanged: (v) => _filter.sort = v, highContrast: highContrast, ), ChipMultiSelector( title: 'Statuses', items: ReleaseStatus.values.map((v) => (v.label, v)).toList(), values: _filter.statuses, highContrast: highContrast, ), if (widget.ofAnime) ChipMultiSelector( title: 'Formats', items: MediaFormat.animeFormats.map((v) => (v.label, v)).toList(), values: _filter.animeFormats, highContrast: highContrast, ) else ChipMultiSelector( title: 'Formats', items: MediaFormat.mangaFormats.map((v) => (v.label, v)).toList(), values: _filter.mangaFormats, highContrast: highContrast, ), if (widget.ofAnime) ChipSelector( title: 'Season', items: MediaSeason.values.map((v) => (v.label, v)).toList(), value: _filter.season, onChanged: (v) => _filter.season = v, highContrast: highContrast, ), const SizedBox(height: 5), const Divider(), Consumer( builder: (context, ref, _) => ref .watch(tagsProvider) .when( loading: () => const Center(child: Loader()), error: (_, _) => const Center(child: Text('Failed to load tags')), data: (tags) => TagPicker( includedGenres: _filter.genreIn, excludedGenres: _filter.genreNotIn, includedTags: _filter.tagIn, excludedTags: _filter.tagNotIn, ), ), ), const Divider(), const SizedBox(height: Theming.offset), YearRangePicker( title: 'Release Year Range', from: _filter.startYearFrom, to: _filter.startYearTo, onChanged: (from, to) { _filter.startYearFrom = from; _filter.startYearTo = to; }, ), const SizedBox(height: Theming.offset), const Divider(), ChipSelector( title: 'Country', items: OriginCountry.values.map((v) => (v.label, v)).toList(), value: _filter.country, onChanged: (v) => _filter.country = v, highContrast: highContrast, ), ChipMultiSelector( title: 'Sources', items: MediaSource.values.map((v) => (v.label, v)).toList(), values: _filter.sources, highContrast: highContrast, ), ChipSelector( title: 'List Presence', items: const [('In Lists', true), ('Not in Lists', false)], value: _filter.inLists, onChanged: (v) => _filter.inLists = v, highContrast: highContrast, ), ChipSelector( title: 'Age Restriction', items: const [('Adult', true), ('Non-Adult', false)], value: _filter.isAdult, onChanged: (v) => _filter.isAdult = v, highContrast: highContrast, ), ChipSelector( title: 'Licensing', items: const [('Licensed', true), ('Doujin', false)], value: _filter.isLicensed, onChanged: (v) => _filter.isLicensed = v, highContrast: highContrast, ), SizedBox( height: MediaQuery.paddingOf(context).bottom + BottomBar.height + Theming.offset, ), ], ), ), ); } } ================================================ FILE: lib/feature/discover/discover_media_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/discover/discover_model.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/text_rail.dart'; class DiscoverMediaGrid extends StatelessWidget { const DiscoverMediaGrid(this.items, {required this.highContrast}); final List items; final bool highContrast; @override Widget build(BuildContext context) { if (items.isEmpty) { return const SliverFillRemaining(child: Center(child: Text('No Media'))); } final textTheme = TextTheme.of(context); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!); final labelSmallLineHeight = context.lineHeight(textTheme.labelSmall!); final tileHeight = bodyMediumLineHeight + labelMediumLineHeight * 2 + labelSmallLineHeight + 16; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 290, height: tileHeight), delegate: SliverChildBuilderDelegate( childCount: items.length, (context, index) => _Tile(items[index], highContrast, tileHeight), ), ); } } class _Tile extends StatelessWidget { const _Tile(this.item, this.highContrast, this.tileHeight); final DiscoverMediaItem item; final bool highContrast; final double tileHeight; @override Widget build(BuildContext context) { final textRailItems = {}; if (item.format != null) textRailItems[item.format!] = false; if (item.releaseStatus != null) { textRailItems[item.releaseStatus!.label] = false; } if (item.releaseYear != null) { textRailItems[item.releaseYear!.toString()] = false; } if (item.entryStatus != null) { textRailItems[item.entryStatus!.label(item.isAnime)] = true; } if (item.isAdult) textRailItems['Adult'] = true; final detailTextStyle = TextTheme.of(context).labelSmall; return CardExtension.highContrast(highContrast)( child: MediaRouteTile( id: item.id, imageUrl: item.imageUrl, child: Row( children: [ Hero( tag: item.id, child: ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: Container( width: tileHeight / Theming.coverHtoWRatio, color: ColorScheme.of(context).surfaceContainerHighest, child: CachedImage(item.imageUrl), ), ), ), Expanded( child: Padding( padding: .symmetric(horizontal: Theming.offset, vertical: 5), child: Column( crossAxisAlignment: .start, mainAxisAlignment: .spaceAround, spacing: 3, children: [ Flexible( child: Column( mainAxisSize: .min, crossAxisAlignment: .start, mainAxisAlignment: .spaceBetween, spacing: 3, children: [ Flexible(child: Text(item.name, overflow: .ellipsis, maxLines: 2)), TextRail(textRailItems, style: TextTheme.of(context).labelMedium), ], ), ), Row( spacing: 5, children: [ Icon( Icons.percent_rounded, size: 15, color: ColorScheme.of(context).onSurfaceVariant, ), Text( item.averageScore.toString(), style: detailTextStyle, overflow: .ellipsis, maxLines: 1, ), Icon( Icons.person_outline_rounded, size: 15, color: ColorScheme.of(context).onSurfaceVariant, ), Text(item.popularity.toString(), style: detailTextStyle), ], ), ], ), ), ), ], ), ), ); } } ================================================ FILE: lib/feature/discover/discover_media_simple_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/discover/discover_model.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; class DiscoverMediaSimpleGrid extends StatelessWidget { const DiscoverMediaSimpleGrid(this.items, {required this.highContrast}); final List items; final bool highContrast; @override Widget build(BuildContext context) { final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final textHeight = lineHeight * 2 + 10; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight( minWidth: 100, extraHeight: textHeight, rawHWRatio: Theming.coverHtoWRatio, ), delegate: SliverChildBuilderDelegate( (_, i) => _Tile(items[i], highContrast, textHeight), childCount: items.length, ), ); } } class _Tile extends StatelessWidget { const _Tile(this.item, this.highContrast, this.textHeight); final DiscoverMediaItem item; final bool highContrast; final double textHeight; @override Widget build(BuildContext context) { return MediaRouteTile( id: item.id, imageUrl: item.imageUrl, child: CardExtension.highContrast(highContrast)( child: Column( crossAxisAlignment: .stretch, children: [ Expanded( child: Hero( tag: item.id, child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall), child: CachedImage(item.imageUrl), ), ), ), SizedBox( height: textHeight, child: Padding( padding: const .all(5), child: Text(item.name, maxLines: 2, overflow: .ellipsis), ), ), ], ), ), ); } } ================================================ FILE: lib/feature/discover/discover_model.dart ================================================ import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/feature/character/character_item_model.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/staff/staff_item_model.dart'; import 'package:otraku/feature/studio/studio_item_model.dart'; import 'package:otraku/feature/user/user_item_model.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/review/review_models.dart'; enum DiscoverType { anime('Anime'), manga('Manga'), character('Character'), staff('Staff'), studio('Studio'), user('User'), review('Review'), recommendation('Recommendation'); const DiscoverType(this.label); final String label; } enum DiscoverItemView { detailed, simple } sealed class DiscoverItems { const DiscoverItems(); } class DiscoverAnimeItems extends DiscoverItems { const DiscoverAnimeItems([this.pages = const Paged()]); final Paged pages; } class DiscoverMangaItems extends DiscoverItems { const DiscoverMangaItems([this.pages = const Paged()]); final Paged pages; } class DiscoverCharacterItems extends DiscoverItems { const DiscoverCharacterItems([this.pages = const Paged()]); final Paged pages; } class DiscoverStaffItems extends DiscoverItems { const DiscoverStaffItems([this.pages = const Paged()]); final Paged pages; } class DiscoverStudioItems extends DiscoverItems { const DiscoverStudioItems([this.pages = const Paged()]); final Paged pages; } class DiscoverUserItems extends DiscoverItems { const DiscoverUserItems([this.pages = const Paged()]); final Paged pages; } class DiscoverReviewItems extends DiscoverItems { const DiscoverReviewItems([this.pages = const Paged()]); final Paged pages; } class DiscoverRecommendationItems extends DiscoverItems { const DiscoverRecommendationItems([this.pages = const Paged()]); final Paged pages; } class DiscoverMediaItem { DiscoverMediaItem._({ required this.id, required this.name, required this.imageUrl, required this.isAnime, required this.format, required this.releaseStatus, required this.entryStatus, required this.releaseYear, required this.averageScore, required this.popularity, required this.isAdult, }); factory DiscoverMediaItem(Map map, ImageQuality imageQuality) => DiscoverMediaItem._( id: map['id'], name: map['title']['userPreferred'], imageUrl: map['coverImage'][imageQuality.value], isAnime: map['type'] == 'ANIME', format: StringExtension.tryNoScreamingSnakeCase(map['format']), releaseStatus: ReleaseStatus.from(map['status']), entryStatus: ListStatus.from(map['mediaListEntry']?['status']), releaseYear: map['startDate']?['year'], averageScore: map['averageScore'] ?? 0, popularity: map['popularity'] ?? 0, isAdult: map['isAdult'] ?? false, ); final int id; final String name; final String imageUrl; final bool isAnime; final String? format; final ReleaseStatus? releaseStatus; final ListStatus? entryStatus; final int? releaseYear; final int averageScore; final int popularity; final bool isAdult; } class DiscoverRecommendationItem { DiscoverRecommendationItem._({ required this.rating, required this.userRating, required this.mediaId, required this.mediaTitle, required this.mediaCover, required this.mediaListStatus, required this.isMediaAdult, required this.recommendedMediaId, required this.recommendedMediaTitle, required this.recommendedMediaCover, required this.recommendedMediaListStatus, required this.isRecommendedMediaAdult, }); factory DiscoverRecommendationItem(Map map, ImageQuality imageQuality) { final userRating = map['userRating'] == 'RATE_UP' ? true : map['userRating'] == 'RATE_DOWN' ? false : null; final media = map['media']; final recommendedMedia = map['mediaRecommendation']; final isMediaAnime = switch (media['type']) { 'ANIME' => true, 'MANGA' => false, _ => null, }; final isRecommendedMediaAnime = switch (media['type']) { 'ANIME' => true, 'MANGA' => false, _ => null, }; return DiscoverRecommendationItem._( userRating: userRating, rating: map['rating'] ?? 0, mediaId: media['id'] ?? 0, mediaTitle: media['title']['userPreferred'] ?? '?', mediaCover: media['coverImage'][imageQuality.value] ?? '', mediaListStatus: ListStatus.from(media['mediaListEntry']?['status'])?.label(isMediaAnime), isMediaAdult: media['isAdult'] ?? false, recommendedMediaId: recommendedMedia['id'] ?? 0, recommendedMediaTitle: recommendedMedia['title']['userPreferred'] ?? '?', recommendedMediaCover: recommendedMedia['coverImage'][imageQuality.value] ?? '', recommendedMediaListStatus: ListStatus.from( recommendedMedia['mediaListEntry']?['status'], )?.label(isRecommendedMediaAnime), isRecommendedMediaAdult: recommendedMedia['isAdult'] ?? false, ); } int rating; bool? userRating; final int mediaId; final String mediaTitle; final String mediaCover; final String? mediaListStatus; final bool isMediaAdult; final int recommendedMediaId; final String recommendedMediaTitle; final String recommendedMediaCover; final String? recommendedMediaListStatus; final bool isRecommendedMediaAdult; } ================================================ FILE: lib/feature/discover/discover_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/feature/character/character_item_model.dart'; import 'package:otraku/feature/discover/discover_filter_model.dart'; import 'package:otraku/feature/staff/staff_item_model.dart'; import 'package:otraku/feature/studio/studio_item_model.dart'; import 'package:otraku/feature/user/user_item_model.dart'; import 'package:otraku/feature/discover/discover_filter_provider.dart'; import 'package:otraku/feature/discover/discover_model.dart'; import 'package:otraku/feature/review/review_models.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final discoverProvider = AsyncNotifierProvider( DiscoverNotifier.new, ); class DiscoverNotifier extends AsyncNotifier { late DiscoverFilter filter; @override FutureOr build() { filter = ref.watch(discoverFilterProvider); return switch (filter.type) { .anime => _fetchAnime(const DiscoverAnimeItems()), .manga => _fetchManga(const DiscoverMangaItems()), .character => _fetchCharacters(const DiscoverCharacterItems()), .staff => _fetchStaff(const DiscoverStaffItems()), .studio => _fetchStudios(const DiscoverStudioItems()), .user => _fetchUsers(const DiscoverUserItems()), .review => _fetchReviews(const DiscoverReviewItems()), .recommendation => _fetchRecommendations(const DiscoverRecommendationItems()), }; } Future fetch() async { final oldValue = state.value; state = await AsyncValue.guard( () => switch (filter.type) { .anime => _fetchAnime( (oldValue is DiscoverAnimeItems) ? oldValue : const DiscoverAnimeItems(), ), .manga => _fetchManga( (oldValue is DiscoverMangaItems) ? oldValue : const DiscoverMangaItems(), ), .character => _fetchCharacters( (oldValue is DiscoverCharacterItems) ? oldValue : const DiscoverCharacterItems(), ), .staff => _fetchStaff( (oldValue is DiscoverStaffItems) ? oldValue : const DiscoverStaffItems(), ), .studio => _fetchStudios( (oldValue is DiscoverStudioItems) ? oldValue : const DiscoverStudioItems(), ), .user => _fetchUsers( (oldValue is DiscoverUserItems) ? oldValue : const DiscoverUserItems(), ), .review => _fetchReviews( (oldValue is DiscoverReviewItems) ? oldValue : const DiscoverReviewItems(), ), .recommendation => _fetchRecommendations( (oldValue is DiscoverRecommendationItems) ? oldValue : const DiscoverRecommendationItems(), ), }, ); } Future _fetchAnime(DiscoverAnimeItems oldValue) async { final data = await ref.read(repositoryProvider).request(GqlQuery.mediaPage, { 'page': oldValue.pages.next, 'type': 'ANIME', if (filter.search.isNotEmpty) ...{ 'search': filter.search, ...filter.mediaFilter.toGraphQlVariables(ofAnime: true)..['sort'] = 'SEARCH_MATCH', } else ...filter.mediaFilter.toGraphQlVariables(ofAnime: true), }); final imageQuality = ref.read(persistenceProvider).options.imageQuality; final items = []; for (final m in data['Page']['media']) { items.add(DiscoverMediaItem(m, imageQuality)); } return DiscoverAnimeItems( oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false), ); } Future _fetchManga(DiscoverMangaItems oldValue) async { final data = await ref.read(repositoryProvider).request(GqlQuery.mediaPage, { 'page': oldValue.pages.next, 'type': 'MANGA', if (filter.search.isNotEmpty) ...{ 'search': filter.search, ...filter.mediaFilter.toGraphQlVariables(ofAnime: false)..['sort'] = 'SEARCH_MATCH', } else ...filter.mediaFilter.toGraphQlVariables(ofAnime: false), }); final imageQuality = ref.read(persistenceProvider).options.imageQuality; final items = []; for (final m in data['Page']['media']) { items.add(DiscoverMediaItem(m, imageQuality)); } return DiscoverMangaItems( oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false), ); } Future _fetchCharacters(DiscoverCharacterItems oldValue) async { final data = await ref.read(repositoryProvider).request(GqlQuery.characterPage, { 'page': oldValue.pages.next, if (filter.search.isNotEmpty) 'search': filter.search, if (filter.hasBirthday) 'isBirthday': true, }); final items = []; for (final c in data['Page']['characters']) { items.add(CharacterItem(c)); } return DiscoverCharacterItems( oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false), ); } Future _fetchStaff(DiscoverStaffItems oldValue) async { final data = await ref.read(repositoryProvider).request(GqlQuery.staffPage, { 'page': oldValue.pages.next, if (filter.search.isNotEmpty) 'search': filter.search, if (filter.hasBirthday) 'isBirthday': true, }); final items = []; for (final s in data['Page']['staff']) { items.add(StaffItem(s)); } return DiscoverStaffItems( oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false), ); } Future _fetchStudios(DiscoverStudioItems oldValue) async { final data = await ref.read(repositoryProvider).request(GqlQuery.studioPage, { 'page': oldValue.pages.next, if (filter.search.isNotEmpty) 'search': filter.search, }); final items = []; for (final s in data['Page']['studios']) { items.add(StudioItem(s)); } return DiscoverStudioItems( oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false), ); } Future _fetchUsers(DiscoverUserItems oldValue) async { final data = await ref.read(repositoryProvider).request(GqlQuery.userPage, { 'page': oldValue.pages.next, if (filter.search.isNotEmpty) 'search': filter.search, }); final items = []; for (final u in data['Page']['users']) { items.add(UserItem(u)); } return DiscoverUserItems( oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false), ); } Future _fetchReviews(DiscoverReviewItems oldValue) async { final data = await ref.read(repositoryProvider).request(GqlQuery.reviewPage, { 'page': oldValue.pages.next, 'sort': filter.reviewsFilter.sort.value, if (filter.reviewsFilter.mediaType != null) 'mediaType': filter.reviewsFilter.mediaType!.value, }); final items = []; for (final r in data['Page']['reviews']) { items.add(ReviewItem(r)); } return DiscoverReviewItems( oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false), ); } Future _fetchRecommendations(DiscoverRecommendationItems oldValue) async { final data = await ref.read(repositoryProvider).request(GqlQuery.recommendationsPage, { 'page': oldValue.pages.next, 'sort': filter.recommendationsFilter.sort.value, if (filter.recommendationsFilter.inLists != null) 'onList': filter.recommendationsFilter.inLists, }); final imageQuality = ref.read(persistenceProvider).options.imageQuality; final items = []; for (final r in data['Page']['recommendations']) { items.add(DiscoverRecommendationItem(r, imageQuality)); } return DiscoverRecommendationItems( oldValue.pages.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false), ); } Future rateRecommendation(int mediaId, int recommendedMediaId, bool? rating) { return ref.read(repositoryProvider).request(GqlMutation.rateRecommendation, { 'id': mediaId, 'recommendedId': recommendedMediaId, 'rating': rating == null ? 'NO_RATING' : rating ? 'RATE_UP' : 'RATE_DOWN', }).getErrorOrNull(); } } ================================================ FILE: lib/feature/discover/discover_recommendations_filter_sheet.dart ================================================ import 'package:flutter/widgets.dart'; import 'package:otraku/feature/discover/discover_filter_model.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/widget/sheets.dart'; Future showRecommendationsFilterSheet({ required BuildContext context, required DiscoverRecommendationsFilter filter, required void Function(DiscoverRecommendationsFilter) onDone, required bool highContrast, }) { return showSheet( context, SimpleSheet( initialHeight: Theming.normalTapTarget * 2.5 + MediaQuery.paddingOf(context).bottom + 40, builder: (context, scrollCtrl) => ListView( controller: scrollCtrl, physics: Theming.bouncyPhysics, padding: const .symmetric(horizontal: Theming.offset, vertical: 20), children: [ ChipSelector.ensureSelected( title: 'Sort', items: const [ ('Recent', RecommendationsSort.recent), ('Highest Rated', RecommendationsSort.highestRated), ('Lowest Rated', RecommendationsSort.lowestRated), ], value: filter.sort, onChanged: (v) => filter = filter.copyWith(sort: v), highContrast: highContrast, ), ChipSelector( title: 'List Presence', items: const [('In Lists', true), ('Not in Lists', false)], value: filter.inLists, onChanged: (v) => filter = filter.copyWith(inLists: (v,)), highContrast: highContrast, ), ], ), ), ).then((_) => onDone(filter)); } ================================================ FILE: lib/feature/discover/discover_recommendations_grid.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/discover/discover_model.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; typedef OnRateRecommendation = Future Function(int mediaId, int recommendedMediaId, bool? rating); class DiscoverRecommendationsGrid extends StatelessWidget { const DiscoverRecommendationsGrid(this.items, {required this.onRate, required this.highContrast}); final List items; final OnRateRecommendation onRate; final bool highContrast; @override Widget build(BuildContext context) { if (items.isEmpty) { return const SliverFillRemaining(child: Center(child: Text('No items'))); } final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final presentationHeight = bodyMediumLineHeight * 4 + 13; final ratingHeight = max(bodyMediumLineHeight, Theming.minTapTarget); final tileHeight = presentationHeight + ratingHeight; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 300, height: tileHeight), delegate: SliverChildBuilderDelegate( childCount: items.length, (context, i) => _Tile(items[i], onRate, highContrast, presentationHeight), ), ); } } class _Tile extends StatelessWidget { const _Tile(this.item, this.onRate, this.highContrast, this.presentationHeight); final DiscoverRecommendationItem item; final OnRateRecommendation onRate; final bool highContrast; final double presentationHeight; @override Widget build(BuildContext context) { final coverWidth = presentationHeight / Theming.coverHtoWRatio; return CardExtension.highContrast(highContrast)( child: Column( children: [ SizedBox( height: presentationHeight, child: Row( children: [ MediaRouteTile( id: item.mediaId, imageUrl: item.mediaCover, child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage(item.mediaCover, width: coverWidth), ), ), Expanded( child: Padding( padding: const .symmetric(horizontal: Theming.offset, vertical: 5), child: Column( crossAxisAlignment: .stretch, mainAxisAlignment: .spaceAround, children: [ GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.media(item.mediaId, item.mediaCover)), child: Text(item.mediaTitle, overflow: .ellipsis, maxLines: 2), ), const Divider(height: 3), GestureDetector( behavior: .opaque, onTap: () => context.push( Routes.media(item.recommendedMediaId, item.recommendedMediaCover), ), child: Text( item.recommendedMediaTitle, overflow: .ellipsis, textAlign: .end, maxLines: 2, ), ), ], ), ), ), MediaRouteTile( id: item.recommendedMediaId, imageUrl: item.recommendedMediaCover, child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage(item.recommendedMediaCover, width: coverWidth), ), ), ], ), ), Padding( padding: const .symmetric(horizontal: Theming.offset), child: Row( spacing: 5, children: [ Expanded( child: item.mediaListStatus == null ? const SizedBox() : Text( item.mediaListStatus!, textAlign: .left, overflow: .ellipsis, maxLines: 1, ), ), _RecommendationButtons(item, onRate), Expanded( child: item.recommendedMediaListStatus == null ? const SizedBox() : Text( item.recommendedMediaListStatus!, textAlign: .right, overflow: .ellipsis, maxLines: 1, ), ), ], ), ), ], ), ); } } class _RecommendationButtons extends StatefulWidget { const _RecommendationButtons(this.item, this.onRate); final DiscoverRecommendationItem item; final OnRateRecommendation onRate; @override State<_RecommendationButtons> createState() => __RecommendationButtonsState(); } class __RecommendationButtonsState extends State<_RecommendationButtons> { @override Widget build(BuildContext context) { final item = widget.item; return Row( spacing: 5, mainAxisAlignment: .center, children: [ IconButton( tooltip: 'Agree', icon: item.userRating == true ? Icon( Icons.thumb_up, size: Theming.iconSmall, color: ColorScheme.of(context).primary, ) : Icon( Icons.thumb_up_outlined, size: Theming.iconSmall, color: ColorScheme.of(context).onSurface, ), onPressed: () async { final oldRating = item.rating; final oldUserRating = item.userRating; setState(() { switch (item.userRating) { case true: item.rating--; item.userRating = null; break; case false: item.rating += 2; item.userRating = true; break; case null: item.rating++; item.userRating = true; break; } }); final err = await widget.onRate(item.mediaId, item.recommendedMediaId, item.userRating); if (err == null) return; setState(() { item.rating = oldRating; item.userRating = oldUserRating; }); if (context.mounted) { SnackBarExtension.show(context, err.toString()); } }, ), Text(item.rating.toString()), IconButton( tooltip: 'Disagree', icon: item.userRating == false ? Icon( Icons.thumb_down, size: Theming.iconSmall, color: ColorScheme.of(context).error, ) : Icon( Icons.thumb_down_outlined, size: Theming.iconSmall, color: ColorScheme.of(context).onSurface, ), onPressed: () async { final oldRating = item.rating; final oldUserRating = item.userRating; setState(() { switch (item.userRating) { case true: item.rating -= 2; item.userRating = false; break; case false: item.rating++; item.userRating = null; break; case null: item.rating--; item.userRating = false; break; } }); final err = await widget.onRate(item.mediaId, item.recommendedMediaId, item.userRating); if (err == null) return; setState(() { item.rating = oldRating; item.userRating = oldUserRating; }); if (context.mounted) { SnackBarExtension.show(context, err.toString()); } }, ), ], ); } } ================================================ FILE: lib/feature/discover/discover_top_bar.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/discover/discover_filter_model.dart'; import 'package:otraku/feature/discover/discover_filter_provider.dart'; import 'package:otraku/feature/discover/discover_media_filter_view.dart'; import 'package:otraku/feature/discover/discover_recommendations_filter_sheet.dart'; import 'package:otraku/feature/review/reviews_filter_sheet.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/util/debounce.dart'; import 'package:otraku/widget/input/search_field.dart'; import 'package:otraku/widget/sheets.dart'; class DiscoverTopBarTrailingContent extends StatelessWidget { const DiscoverTopBarTrailingContent(this.focusNode); final FocusNode focusNode; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { final filter = ref.watch(discoverFilterProvider); final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast)); return Expanded( child: Row( children: [ Expanded( child: switch (filter.type) { .review => Text( 'Reviews', maxLines: 1, overflow: .ellipsis, style: TextTheme.of(context).bodyMedium, ), .recommendation => Text( 'Recommendations', maxLines: 1, overflow: .ellipsis, style: TextTheme.of(context).bodyMedium, ), _ => SearchField( debounce: Debounce(), focusNode: focusNode, hint: filter.type.label, value: filter.search, onChanged: (search) => ref .read(discoverFilterProvider.notifier) .update((s) => s.copyWith(search: search)), ), }, ), if (filter.type == .anime) IconButton( tooltip: 'Calendar', icon: const Icon(Ionicons.calendar_outline), onPressed: () => context.push(Routes.calendar), ), switch (filter.type) { .anime || .manga => filter.mediaFilter.isActive ? Badge( smallSize: 10, alignment: Alignment.topLeft, backgroundColor: ColorScheme.of(context).primary, child: _filterIcon(context, ref, filter), ) : _filterIcon(context, ref, filter), .character || .staff => _BirthdayFilter(ref), .review => IconButton( tooltip: 'Filter', icon: const Icon(Ionicons.funnel_outline), onPressed: () => showReviewsFilterSheet( context: context, filter: filter.reviewsFilter, highContrast: highContrast, onDone: (filter) { final discoverFilter = ref.read(discoverFilterProvider); if (filter != discoverFilter.reviewsFilter) { ref .read(discoverFilterProvider.notifier) .update((s) => s.copyWith(reviewsFilter: filter)); } }, ), ), .recommendation => IconButton( tooltip: 'Filter', icon: const Icon(Ionicons.funnel_outline), onPressed: () => showRecommendationsFilterSheet( context: context, filter: filter.recommendationsFilter, highContrast: highContrast, onDone: (filter) { final discoverFilter = ref.read(discoverFilterProvider); if (filter != discoverFilter.recommendationsFilter) { ref .read(discoverFilterProvider.notifier) .update((s) => s.copyWith(recommendationsFilter: filter)); } }, ), ), _ => const SizedBox(width: Theming.offset), }, ], ), ); }, ); } Widget _filterIcon(BuildContext context, WidgetRef ref, DiscoverFilter filter) { return IconButton( tooltip: 'Filter', icon: const Icon(Ionicons.funnel_outline), onPressed: () => showSheet( context, DiscoverMediaFilterView( ofAnime: filter.type == .anime, filter: filter.mediaFilter, onChanged: (mediaFilter) => ref .read(discoverFilterProvider.notifier) .update((s) => s.copyWith(mediaFilter: mediaFilter)), ), ), ); } } class _BirthdayFilter extends StatelessWidget { const _BirthdayFilter(this.ref); final WidgetRef ref; @override Widget build(BuildContext context) { final hasBirthday = ref.watch(discoverFilterProvider.select((s) => s.hasBirthday)); final icon = IconButton( tooltip: 'Birthday Filter', icon: const Icon(Icons.cake_outlined), onPressed: () => ref .read(discoverFilterProvider.notifier) .update((s) => s.copyWith(hasBirthday: !hasBirthday)), ); return hasBirthday ? Badge( smallSize: 10, alignment: Alignment.topLeft, backgroundColor: ColorScheme.of(context).primary, child: icon, ) : icon; } } ================================================ FILE: lib/feature/discover/discover_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/character/character_item_grid.dart'; import 'package:otraku/feature/discover/discover_filter_provider.dart'; import 'package:otraku/feature/discover/discover_media_grid.dart'; import 'package:otraku/feature/discover/discover_media_simple_grid.dart'; import 'package:otraku/feature/discover/discover_model.dart'; import 'package:otraku/feature/discover/discover_provider.dart'; import 'package:otraku/feature/discover/discover_recommendations_grid.dart'; import 'package:otraku/feature/staff/staff_item_grid.dart'; import 'package:otraku/feature/studio/studio_item_grid.dart'; import 'package:otraku/feature/user/user_item_grid.dart'; import 'package:otraku/feature/review/review_grid.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/input/pill_selector.dart'; import 'package:otraku/widget/paged_view.dart'; class DiscoverSubview extends StatelessWidget { const DiscoverSubview(this.scrollCtrl, this.formFactor); final ScrollController scrollCtrl; final FormFactor formFactor; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { final options = ref.watch(persistenceProvider.select((s) => s.options)); final type = ref.watch(discoverFilterProvider.select((s) => s.type)); final onRefresh = (invalidate) => invalidate(discoverProvider); final content = switch (type) { .anime => PagedView( scrollCtrl: scrollCtrl, onRefresh: onRefresh, provider: discoverProvider.select( (s) => s.whenData((data) => (data as DiscoverAnimeItems).pages), ), onData: (data) => options.discoverItemView == .simple ? DiscoverMediaSimpleGrid(data.items, highContrast: options.highContrast) : DiscoverMediaGrid(data.items, highContrast: options.highContrast), ), .manga => PagedView( scrollCtrl: scrollCtrl, onRefresh: onRefresh, provider: discoverProvider.select( (s) => s.whenData((data) => (data as DiscoverMangaItems).pages), ), onData: (data) => options.discoverItemView == .simple ? DiscoverMediaSimpleGrid(data.items, highContrast: options.highContrast) : DiscoverMediaGrid(data.items, highContrast: options.highContrast), ), .character => PagedView( scrollCtrl: scrollCtrl, onRefresh: onRefresh, provider: discoverProvider.select( (s) => s.whenData((data) => (data as DiscoverCharacterItems).pages), ), onData: (data) => CharacterItemGrid(data.items, highContrast: options.highContrast), ), .staff => PagedView( scrollCtrl: scrollCtrl, onRefresh: onRefresh, provider: discoverProvider.select( (s) => s.whenData((data) => (data as DiscoverStaffItems).pages), ), onData: (data) => StaffItemGrid(data.items, highContrast: options.highContrast), ), .studio => PagedView( scrollCtrl: scrollCtrl, onRefresh: onRefresh, provider: discoverProvider.select( (s) => s.whenData((data) => (data as DiscoverStudioItems).pages), ), onData: (data) => StudioItemGrid(data.items, highContrast: options.highContrast), ), .user => PagedView( scrollCtrl: scrollCtrl, onRefresh: onRefresh, provider: discoverProvider.select( (s) => s.whenData((data) => (data as DiscoverUserItems).pages), ), onData: (data) => UserItemGrid(data.items, highContrast: options.highContrast), ), .review => PagedView( scrollCtrl: scrollCtrl, onRefresh: onRefresh, provider: discoverProvider.select( (s) => s.whenData((data) => (data as DiscoverReviewItems).pages), ), onData: (data) => ReviewGrid(data.items, options.highContrast), ), .recommendation => PagedView( scrollCtrl: scrollCtrl, onRefresh: onRefresh, provider: discoverProvider.select( (s) => s.whenData((data) => (data as DiscoverRecommendationItems).pages), ), onData: (data) => DiscoverRecommendationsGrid( data.items, onRate: (mediaId, recommendedMediaId, rating) => ref .read(discoverProvider.notifier) .rateRecommendation(mediaId, recommendedMediaId, rating), highContrast: options.highContrast, ), ), }; if (formFactor == .phone) return content; return Row( children: [ PillSelector( selected: type.index, maxWidth: 180, items: DiscoverType.values.map((v) => Text(v.label)).toList(), onTap: (i) => ref .read(discoverFilterProvider.notifier) .update((s) => s.copyWith(type: DiscoverType.values[i])), ), Expanded(child: content), ], ); }, ); } } ================================================ FILE: lib/feature/edit/edit_buttons.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/edit/edit_model.dart'; import 'package:otraku/feature/edit/edit_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/navigation_tool.dart'; import 'package:otraku/widget/dialogs.dart'; class EditButtons extends StatelessWidget { const EditButtons(this.ref, this.tag, this.entryEdit, this.callback); final WidgetRef ref; final EditTag tag; final EntryEdit? entryEdit; final void Function(EntryEdit)? callback; @override Widget build(BuildContext context) { final entryEdit = this.entryEdit; if (entryEdit == null) return const SizedBox(); final saveButton = BottomBarButton( text: 'Save', icon: Ionicons.save_outline, onTap: () async { final err = await ref.read(entryEditProvider(tag).notifier).save(); if (err == null) { callback?.call(entryEdit); if (context.mounted) Navigator.pop(context); return; } if (context.mounted) { SnackBarExtension.show(context, 'Could not update entry'); Navigator.pop(context); } }, ); final removeButton = entryEdit.baseEntry.entryId == null ? const Spacer() : BottomBarButton( text: 'Remove', icon: Ionicons.trash_bin_outline, foregroundColor: ColorScheme.of(context).error, onTap: () => ConfirmationDialog.show( context, title: 'Remove entry?', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: () async { final err = await ref.read(entryEditProvider(tag).notifier).remove(); if (err == null) { callback?.call(entryEdit); if (context.mounted) Navigator.pop(context); return; } if (context.mounted) { SnackBarExtension.show(context, 'Could not remove entry'); Navigator.pop(context); } }, ), ); return BottomBar( Theming.of(context).rightButtonOrientation ? [removeButton, saveButton] : [saveButton, removeButton], ); } } ================================================ FILE: lib/feature/edit/edit_model.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/settings/settings_model.dart'; typedef EditTag = ({int id, bool setComplete}); class EntryEdit { EntryEdit._({ required this.baseEntry, required this.listStatus, required this.progress, required this.progressVolumes, required this.score, required this.repeat, required this.startedAt, required this.completedAt, required this.private, required this.hiddenFromStatusLists, required this.advancedScores, required this.customLists, required this.notes, }); factory EntryEdit(Map map, Settings settings, bool setComplete) { final baseEntry = BaseEntry(map); final customLists = {}; if (map['mediaListEntry']?['customLists'] != null) { for (final e in map['mediaListEntry']['customLists'].entries) { customLists[e.key] = e.value; } } else { if (map['type'] == 'ANIME') { for (final listName in settings.animeCustomLists) { customLists[listName] = false; } } else { for (final listName in settings.mangaCustomLists) { customLists[listName] = false; } } } final advancedScores = {}; if (map['mediaListEntry']?['advancedScores'] != null) { for (final e in map['mediaListEntry']['advancedScores'].entries) { advancedScores[e.key] = e.value.toDouble(); } } else if (settings.advancedScoringEnabled) { for (final scoreCategory in settings.advancedScoreSections) { advancedScores[scoreCategory] = 0; } } var listStatus = baseEntry.listStatus; var completedAt = baseEntry.completedAt; var progress = baseEntry.progress; if (setComplete) { listStatus = .completed; completedAt ??= DateTime.now(); if (baseEntry.progressMax != null) progress = baseEntry.progressMax!; } return EntryEdit._( baseEntry: baseEntry, listStatus: listStatus, progress: progress, progressVolumes: baseEntry.progressVolumes, score: (map['mediaListEntry']?['score'] ?? 0).toDouble(), repeat: map['mediaListEntry']?['repeat'] ?? 0, notes: map['mediaListEntry']?['notes'] ?? '', startedAt: baseEntry.startedAt, completedAt: completedAt, private: map['mediaListEntry']?['private'] ?? false, hiddenFromStatusLists: map['mediaListEntry']?['hiddenFromStatusLists'] ?? false, advancedScores: advancedScores, customLists: customLists, ); } final BaseEntry baseEntry; final ListStatus? listStatus; final int progress; final DateTime? startedAt; final DateTime? completedAt; final Map advancedScores; final Map customLists; int progressVolumes; double score; int repeat; String notes; bool private; bool hiddenFromStatusLists; EntryEdit copyWith({ ListStatus? listStatus, int? progress, int? progressVolumes, double? score, int? repeat, String? notes, (DateTime?,)? startedAt, (DateTime?,)? completedAt, bool? private, bool? hiddenFromStatusLists, Map? advancedScores, Map? customLists, }) => EntryEdit._( baseEntry: baseEntry, listStatus: listStatus ?? this.listStatus, progress: progress ?? this.progress, progressVolumes: progressVolumes ?? this.progressVolumes, score: score ?? this.score, repeat: repeat ?? this.repeat, notes: notes ?? this.notes, startedAt: startedAt == null ? this.startedAt : startedAt.$1, completedAt: completedAt == null ? this.completedAt : completedAt.$1, private: private ?? this.private, hiddenFromStatusLists: hiddenFromStatusLists ?? this.hiddenFromStatusLists, advancedScores: advancedScores ?? this.advancedScores, customLists: customLists ?? this.customLists, ); Map toGraphQlVariables() => { 'mediaId': baseEntry.mediaId, 'status': (listStatus ?? ListStatus.current).value, 'progress': progress, 'progressVolumes': progressVolumes, 'score': score, 'repeat': repeat, 'notes': notes, 'startedAt': startedAt?.fuzzyDate, 'completedAt': completedAt?.fuzzyDate, 'private': private, 'hiddenFromStatusLists': hiddenFromStatusLists, 'advancedScores': advancedScores.entries.map((e) => e.value).toList(), 'customLists': customLists.entries.where((e) => e.value).map((e) => e.key).toList(), }; } class BaseEntry { const BaseEntry._({ required this.mediaId, required this.entryId, required this.isAnime, required this.listStatus, required this.progress, required this.progressMax, required this.progressVolumes, required this.progressVolumesMax, required this.startedAt, required this.completedAt, }); factory BaseEntry(Map map) => BaseEntry._( mediaId: map['id'], entryId: map['mediaListEntry']?['id'], isAnime: map['type'] == 'ANIME', listStatus: ListStatus.from(map['mediaListEntry']?['status']), progress: map['mediaListEntry']?['progress'] ?? 0, progressMax: map['episodes'] ?? map['chapters'], progressVolumes: map['mediaListEntry']?['progressVolumes'] ?? 0, progressVolumesMax: map['volumes'], startedAt: DateTimeExtension.fromFuzzyDate(map['mediaListEntry']?['startedAt']), completedAt: DateTimeExtension.fromFuzzyDate(map['mediaListEntry']?['completedAt']), ); final int mediaId; final int? entryId; final bool isAnime; final ListStatus? listStatus; final int progress; final int? progressMax; final int progressVolumes; final int? progressVolumesMax; final DateTime? startedAt; final DateTime? completedAt; } ================================================ FILE: lib/feature/edit/edit_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/feature/collection/collection_provider.dart'; import 'package:otraku/feature/edit/edit_model.dart'; import 'package:otraku/feature/media/media_provider.dart'; import 'package:otraku/feature/settings/settings_provider.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final entryEditProvider = AsyncNotifierProvider.autoDispose .family(EntryEditNotifier.new); class EntryEditNotifier extends AsyncNotifier { EntryEditNotifier(this.arg); final EditTag arg; @override FutureOr build() async { if (ref.exists(mediaProvider(arg.id))) { return ref.watch(mediaProvider(arg.id).selectAsync((s) => s.entryEdit)); } final data = await ref.watch(repositoryProvider).request(GqlQuery.entry, {'mediaId': arg.id}); final settings = await ref.watch(settingsProvider.selectAsync((settings) => settings)); return EntryEdit(data['Media'], settings, arg.setComplete); } void updateBy(EntryEdit Function(EntryEdit) callback) => state = switch (state) { AsyncData(:final value) => AsyncData(callback(value)), _ => state, }; Future save() async { final value = state.value; if (value == null) return null; state = const AsyncLoading(); final err = await ref .read(repositoryProvider) .request(GqlMutation.updateEntry, value.toGraphQlVariables()) .getErrorOrNull(); if (err != null) { state = AsyncValue.data(value); return err; } final viewerId = ref.read(viewerIdProvider); if (viewerId == null) return null; final tag = (userId: viewerId, ofAnime: value.baseEntry.isAnime); ref .read(collectionProvider(tag).notifier) .saveEntry(value.baseEntry.mediaId, value.baseEntry.listStatus); return null; } Future remove() async { final value = state.value; if (value == null || value.baseEntry.entryId == null) return null; state = const AsyncLoading(); final err = await ref.read(repositoryProvider).request(GqlMutation.removeEntry, { 'entryId': value.baseEntry.entryId, }).getErrorOrNull(); if (err != null) { state = AsyncValue.data(value); return err; } final viewerId = ref.read(viewerIdProvider); if (viewerId == null) return null; final tag = (userId: viewerId, ofAnime: value.baseEntry.isAnime); ref.read(collectionProvider(tag).notifier).removeEntry(value.baseEntry.mediaId); return null; } } ================================================ FILE: lib/feature/edit/edit_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/settings/settings_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/input/stateful_tiles.dart'; import 'package:otraku/widget/layout/navigation_tool.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/edit/edit_buttons.dart'; import 'package:otraku/feature/edit/edit_model.dart'; import 'package:otraku/feature/edit/edit_provider.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/feature/settings/settings_provider.dart'; import 'package:otraku/widget/input/date_field.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/input/number_field.dart'; import 'package:otraku/feature/edit/score_field.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; class EditView extends ConsumerWidget { const EditView(this.tag, {this.callback}); final EditTag tag; final void Function(EntryEdit)? callback; @override Widget build(BuildContext context, WidgetRef ref) { final viewerId = ref.watch(viewerIdProvider); if (viewerId == null) { return SimpleSheet( builder: (context, scrollCtrl) => const Center( child: Padding(padding: Theming.paddingAll, child: Text('Log in to edit media')), ), ); } return switch (ref.watch(entryEditProvider(tag))) { AsyncData(:final value) => SheetWithButtonRow( buttons: EditButtons(ref, tag, value, callback), builder: (context, scrollCtrl) => _EditView(scrollCtrl, tag, value), ), AsyncError(:final error) => SheetWithButtonRow( buttons: EditButtons(ref, tag, null, callback), builder: (context, scrollCtrl) => Center( child: Padding( padding: Theming.paddingAll, child: Text('Failed to load edit sheet: $error'), ), ), ), AsyncLoading() => SheetWithButtonRow( buttons: EditButtons(ref, tag, null, callback), builder: (context, scrollCtrl) => const Center( child: Padding(padding: Theming.paddingAll, child: Loader()), ), ), }; } } class _EditView extends ConsumerWidget { const _EditView(this.scrollCtrl, this.tag, this.entryEdit); final ScrollController scrollCtrl; final EditTag tag; final EntryEdit entryEdit; @override Widget build(BuildContext context, WidgetRef ref) { final readableNotifier = entryEditProvider(tag).notifier; final settings = ref.watch(settingsProvider.select((s) => s.value)); final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast)); final statusField = SliverToBoxAdapter( child: Padding( padding: const .symmetric(horizontal: Theming.offset), child: ChipSelector( title: 'Status', items: ListStatus.values.map((v) => (v.label(entryEdit.baseEntry.isAnime), v)).toList(), value: entryEdit.listStatus, highContrast: highContrast, onChanged: (status) => ref.read(readableNotifier).updateBy((s) { var startedAt = s.startedAt; var completedAt = s.completedAt; var progress = s.progress; if (entryEdit.baseEntry.listStatus == null && status == .current && startedAt == null) { startedAt = DateTime.now(); SnackBarExtension.show(context, 'Start date changed'); } else if (entryEdit.baseEntry.listStatus != status && status == .completed && completedAt == null) { completedAt = DateTime.now(); var text = 'Completed date changed'; if (entryEdit.baseEntry.progressMax != null && progress < s.baseEntry.progressMax!) { progress = s.baseEntry.progressMax!; text = 'Completed date & progress changed'; } SnackBarExtension.show(context, text); } return s.copyWith( listStatus: status, progress: progress, startedAt: (startedAt,), completedAt: (completedAt,), ); }), ), ), ); final timelineFields = _FieldGrid( minWidth: 195, children: [ DateField( label: 'Started', value: entryEdit.startedAt, onChanged: (startedAt) => ref.read(readableNotifier).updateBy((s) { var listStatus = s.listStatus; if (startedAt != null && entryEdit.baseEntry.listStatus == null && listStatus == null) { listStatus = .current; SnackBarExtension.show(context, 'Status changed'); } return s.copyWith(listStatus: listStatus, startedAt: (startedAt,)); }), ), DateField( label: 'Completed', value: entryEdit.completedAt, onChanged: (completedAt) => ref.read(readableNotifier).updateBy((s) { var listStatus = s.listStatus; var progress = s.progress; if (completedAt != null && entryEdit.baseEntry.listStatus != .completed && entryEdit.baseEntry.listStatus != .repeating && entryEdit.baseEntry.listStatus == listStatus) { listStatus = .completed; String text = 'Status changed'; if (s.baseEntry.progressMax != null && s.progress < s.baseEntry.progressMax!) { progress = s.baseEntry.progressMax!; text = 'Status & progress changed'; } SnackBarExtension.show(context, text); } return s.copyWith( listStatus: listStatus, progress: progress, completedAt: (completedAt,), ); }), ), NumberField( label: 'Repeat', value: entryEdit.repeat, onChanged: (repeat) => entryEdit.repeat = repeat, ), ], ); return Material( color: Colors.transparent, child: CustomScrollView( controller: scrollCtrl, slivers: [ const SliverToBoxAdapter(child: SizedBox(height: 20)), statusField, const SliverToBoxAdapter(child: SizedBox(height: 15)), _buildProgressFields(context, ref), SliverToBoxAdapter( child: ScoreField( value: entryEdit.score, scoreFormat: settings?.scoreFormat, onChanged: (score) => entryEdit.score = score, ), ), const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)), _buildAdvancedScoringFields(ref, settings), const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)), _Notes(value: entryEdit.notes, onChanged: (notes) => entryEdit.notes = notes), const SliverToBoxAdapter(child: SizedBox(height: 20)), timelineFields, SliverToBoxAdapter( child: StatefulCheckboxListTile( title: const Text('Private'), value: entryEdit.private, onChanged: (private) => entryEdit.private = private!, ), ), SliverToBoxAdapter( child: StatefulCheckboxListTile( title: const Text('Hidden From Status Lists'), value: entryEdit.hiddenFromStatusLists, onChanged: (hiddenFromStatusLists) => entryEdit.hiddenFromStatusLists = hiddenFromStatusLists!, ), ), if (entryEdit.customLists.isNotEmpty) SliverToBoxAdapter( child: ExpansionTile( title: const Text('Custom Lists'), initiallyExpanded: true, children: [ for (final e in entryEdit.customLists.entries) StatefulCheckboxListTile( title: Text(e.key), value: e.value, onChanged: (v) => entryEdit.customLists[e.key] = v!, ), ], ), ), SliverToBoxAdapter( child: SizedBox(height: MediaQuery.paddingOf(context).bottom + BottomBar.height + 10), ), ], ), ); } Widget _buildProgressFields(BuildContext context, WidgetRef ref) { final readableNotifier = entryEditProvider(tag).notifier; final progressField = NumberField( label: 'Progress', value: entryEdit.progress, maxValue: entryEdit.baseEntry.progressMax ?? 100000, onChanged: (progress) => ref.read(readableNotifier).updateBy((s) { var status = s.listStatus; var startedAt = s.startedAt; var completedAt = s.completedAt; String? text; if (progress == entryEdit.baseEntry.progressMax && progress != entryEdit.baseEntry.progress) { if (entryEdit.baseEntry.listStatus == status && status != .completed) { status = .completed; text = 'Status changed'; } if (entryEdit.baseEntry.completedAt == null && completedAt == null) { completedAt = DateTime.now(); text = text == null ? 'Completed date changed' : 'Status & Completed date changed'; } } else if (entryEdit.baseEntry.progress == 0 && entryEdit.baseEntry.progress != progress) { if (entryEdit.baseEntry.listStatus == status && (status == null || status == .planning)) { status = .current; text = 'Status changed'; } if (entryEdit.baseEntry.startedAt == null && startedAt == null) { startedAt = DateTime.now(); text = text == null ? 'Start date changed' : 'Status & start date changed'; } } if (text != null) SnackBarExtension.show(context, text); return s.copyWith( progress: progress, listStatus: status, startedAt: (startedAt,), completedAt: (completedAt,), ); }), ); Widget child = progressField; if (!entryEdit.baseEntry.isAnime) { final volumeProgressField = NumberField( label: 'Volume Progress', value: entryEdit.progressVolumes, maxValue: entryEdit.baseEntry.progressVolumesMax ?? 100000, onChanged: (progressVolumes) => entryEdit.progressVolumes = progressVolumes, ); child = MediaQuery.sizeOf(context).width < Theming.windowWidthMedium ? Column( mainAxisSize: .min, children: [progressField, const SizedBox(height: 20), volumeProgressField], ) : Row( children: Theming.of(context).rightButtonOrientation ? [ Expanded(child: volumeProgressField), const SizedBox(width: Theming.offset), Expanded(child: progressField), ] : [ Expanded(child: progressField), const SizedBox(width: Theming.offset), Expanded(child: volumeProgressField), ], ); } return SliverPadding( padding: const .only(left: Theming.offset, right: Theming.offset, bottom: Theming.offset), sliver: SliverToBoxAdapter(child: child), ); } Widget _buildAdvancedScoringFields(WidgetRef ref, Settings? settings) { final advancedScoringEnabled = settings?.advancedScoringEnabled ?? false; final scoreFormat = settings?.scoreFormat ?? .point10; if (!advancedScoringEnabled || scoreFormat != .point100 && scoreFormat != .point10Decimal) { return const SliverToBoxAdapter(child: SizedBox()); } final scores = entryEdit.advancedScores; final isDecimal = scoreFormat == .point10Decimal; final onChanged = (entry, score) { scores[entry.key] = score.toDouble(); int count = 0; double avg = 0; for (final v in scores.values) { if (v > 0) { avg += v; count++; } } if (count > 0) avg /= count; if (entryEdit.score != avg) { ref.read(entryEditProvider(tag).notifier).updateBy((s) => s.copyWith(score: avg)); } }; return _FieldGrid( minWidth: 140, children: [ for (final s in scores.entries) isDecimal ? NumberField.decimal( label: s.key, value: s.value, maxValue: 10.0, onChanged: (score) => onChanged(s, score), ) : NumberField( label: s.key, value: s.value.toInt(), maxValue: 100, onChanged: (score) => onChanged(s, score), ), ], ); } } class _FieldGrid extends StatelessWidget { const _FieldGrid({required this.minWidth, required this.children}); final List children; final double minWidth; @override Widget build(BuildContext context) { return SliverPadding( padding: const .symmetric(horizontal: Theming.offset), sliver: SliverGrid( delegate: SliverChildListDelegate.fixed(children), gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: minWidth, height: 58), ), ); } } class _Notes extends StatefulWidget { const _Notes({required this.value, required this.onChanged}); final String value; final void Function(String) onChanged; @override _NotesState createState() => _NotesState(); } class _NotesState extends State<_Notes> { late final _ctrl = TextEditingController(text: widget.value); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) => SliverToBoxAdapter( child: Padding( padding: const .symmetric(horizontal: Theming.offset), child: TextField( minLines: 1, maxLines: 10, controller: _ctrl, style: TextTheme.of(context).bodyMedium, decoration: InputDecoration( labelText: 'Notes', labelStyle: TextTheme.of(context).bodyMedium, border: const OutlineInputBorder(), ), onChanged: (value) => widget.onChanged(value), ), ), ); } ================================================ FILE: lib/feature/edit/score_field.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/util/theming.dart'; /// Score picker. class ScoreField extends StatefulWidget { const ScoreField({required this.value, required this.scoreFormat, required this.onChanged}); final double value; final ScoreFormat? scoreFormat; final void Function(double) onChanged; @override State createState() => _ScoreFieldState(); } class _ScoreFieldState extends State { late var _value = widget.value; @override void didUpdateWidget(covariant ScoreField oldWidget) { super.didUpdateWidget(oldWidget); _value = widget.value; } @override Widget build(BuildContext context) { return Padding( padding: const .all(Theming.offset), child: InputDecorator( decoration: const InputDecoration(labelText: 'Score', border: OutlineInputBorder()), child: switch (widget.scoreFormat ?? .point10) { .point3 => _SmileyScorePicker(_value, _onChanged), .point5 => _StarScorePicker(_value, _onChanged), .point10 => _TenScorePicker(_value, _onChanged), .point10Decimal => _TenDecimalScorePicker(_value, _onChanged), .point100 => _HundredScorePicker(_value, _onChanged), }, ), ); } void _onChanged(double value) { setState(() => _value = value); widget.onChanged(value); } } class _SmileyScorePicker extends StatelessWidget { const _SmileyScorePicker(this.score, this.onChanged); final double score; final void Function(double) onChanged; @override Widget build(BuildContext context) { const items = [ (1, Icon(Icons.sentiment_very_dissatisfied), 'Score Disliked'), (2, Icon(Icons.sentiment_neutral), 'Score Neutral'), (3, Icon(Icons.sentiment_very_satisfied), 'Score Liked'), ]; return Row( mainAxisAlignment: .spaceEvenly, children: [ for (final (i, icon, tooltip) in items) IconButton( tooltip: score.floor() != i ? tooltip : 'Unscore', iconSize: 30, icon: icon, color: score.floor() != i ? ColorScheme.of(context).surfaceContainerHighest : ColorScheme.of(context).primary, onPressed: () => score.floor() != i ? onChanged(i.toDouble()) : onChanged(0), ), ], ); } } class _StarScorePicker extends StatelessWidget { const _StarScorePicker(this.score, this.onChanged); final double score; final void Function(double) onChanged; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: .spaceEvenly, children: [ for (int i = 1; i < 6; i++) IconButton( tooltip: score.floor() != i ? 'Score $i Stars' : 'Unscore', iconSize: 30, icon: score >= i ? const Icon(Icons.star_rounded) : const Icon(Icons.star_outline_rounded), color: ColorScheme.of(context).primary, onPressed: () => score.floor() != i ? onChanged(i.toDouble()) : onChanged(0), ), ], ); } } class _TenScorePicker extends StatelessWidget { const _TenScorePicker(this.score, this.onChanged); final double score; final void Function(double) onChanged; @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: Slider.adaptive( value: score.truncateToDouble(), onChanged: onChanged, min: 0, max: 10, divisions: 10, ), ), SizedBox(width: 30, child: Text(score.toStringAsFixed(0))), ], ); } } class _TenDecimalScorePicker extends StatelessWidget { const _TenDecimalScorePicker(this.score, this.onChanged); final double score; final void Function(double) onChanged; @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: Slider.adaptive( value: score, onChanged: (v) => onChanged((v * 10).round() / 10), min: 0, max: 10, divisions: 100, ), ), SizedBox(width: 40, child: Text(score.toStringAsFixed(1))), ], ); } } class _HundredScorePicker extends StatelessWidget { const _HundredScorePicker(this.score, this.onChanged); final double score; final void Function(double) onChanged; @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: Slider.adaptive( value: score, onChanged: onChanged, min: 0, max: 100, divisions: 100, ), ), SizedBox(width: 30, child: Text(score.toStringAsFixed(0))), ], ); } } ================================================ FILE: lib/feature/favorites/favorites_model.dart ================================================ import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/util/paged.dart'; class Favorites { const Favorites({ this.anime = const PagedWithTotal(), this.manga = const PagedWithTotal(), this.characters = const PagedWithTotal(), this.staff = const PagedWithTotal(), this.studios = const PagedWithTotal(), this.edit, }); final PagedWithTotal anime; final PagedWithTotal manga; final PagedWithTotal characters; final PagedWithTotal staff; final PagedWithTotal studios; final FavoritesEdit? edit; int getCount(FavoritesType type) => switch (type) { .anime => anime.total, .manga => manga.total, .characters => characters.total, .staff => staff.total, .studios => studios.total, }; Favorites withEdit(FavoritesEdit? edit) => Favorites( anime: anime, manga: manga, characters: characters, staff: staff, studios: studios, edit: edit, ); } class FavoritesEdit { const FavoritesEdit(this.editedType, this.oldItems); /// The favorites category that is currently being edited. final FavoritesType editedType; /// The favorite items from the category in their original sorting. final List oldItems; } class FavoriteItem { FavoriteItem._({required this.id, required this.name, required this.imageUrl}) : isFavorite = true; factory FavoriteItem.media(Map map, ImageQuality imageQuality) => FavoriteItem._( id: map['id'], name: map['title']['userPreferred'], imageUrl: map['coverImage'][imageQuality.value], ); factory FavoriteItem.character(Map map) => FavoriteItem._( id: map['id'], name: map['name']['userPreferred'], imageUrl: map['image']['large'], ); factory FavoriteItem.staff(Map map) => FavoriteItem._( id: map['id'], name: map['name']['userPreferred'], imageUrl: map['image']['large'], ); factory FavoriteItem.studio(Map map) => FavoriteItem._(id: map['id'], name: map['name'], imageUrl: null); final int id; final String name; final String? imageUrl; bool isFavorite; } enum FavoritesType { anime, manga, characters, staff, studios; String get title => switch (this) { .anime => 'Favourite Anime', .manga => 'Favourite Manga', .characters => 'Favourite Characters', .staff => 'Favourite Staff', .studios => 'Favourite Studios', }; } ================================================ FILE: lib/feature/favorites/favorites_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/favorites/favorites_model.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final favoritesProvider = AsyncNotifierProvider.autoDispose .family(FavoritesNotifier.new); class FavoritesNotifier extends AsyncNotifier { FavoritesNotifier(this.arg); final int arg; @override FutureOr build() => _fetch(const Favorites(), null); Future fetch(FavoritesType type) async { final oldState = state.value ?? const Favorites(); switch (type) { case .anime: if (!oldState.anime.hasNext) return; case .manga: if (!oldState.manga.hasNext) return; case .characters: if (!oldState.characters.hasNext) return; case .staff: if (!oldState.staff.hasNext) return; case .studios: if (!oldState.studios.hasNext) return; } state = await AsyncValue.guard(() => _fetch(oldState, type)); } Future _fetch(Favorites oldState, FavoritesType? type) async { final edit = oldState.edit; final variables = {'userId': arg}; if (type == null) { variables['withAnime'] = true; variables['withManga'] = true; variables['withCharacters'] = true; variables['withStaff'] = true; variables['withStudios'] = true; } else if (type == .anime) { variables['withAnime'] = true; variables['page'] = oldState.anime.next; } else if (type == .manga) { variables['withManga'] = true; variables['page'] = oldState.manga.next; } else if (type == .characters) { variables['withCharacters'] = true; variables['page'] = oldState.characters.next; } else if (type == .staff) { variables['withStaff'] = true; variables['page'] = oldState.staff.next; } else { variables['withStudios'] = true; variables['page'] = oldState.studios.next; } var data = await ref.read(repositoryProvider).request(GqlQuery.favorites, variables); data = data['User']['favourites']; final imageQuality = ref.read(persistenceProvider).options.imageQuality; var anime = oldState.anime; var manga = oldState.manga; var characters = oldState.characters; var staff = oldState.staff; var studios = oldState.studios; if (type == null || type == .anime) { final map = data['anime']; final items = []; for (final a in map['nodes']) { items.add(FavoriteItem.media(a, imageQuality)); } anime = anime.withNext( items, map['pageInfo']['hasNextPage'] ?? false, map['pageInfo']['total'], ); if (edit?.editedType == .anime) { edit!.oldItems.addAll(items); } } if (type == null || type == .manga) { final map = data['manga']; final items = []; for (final m in map['nodes']) { items.add(FavoriteItem.media(m, imageQuality)); } manga = manga.withNext( items, map['pageInfo']['hasNextPage'] ?? false, map['pageInfo']['total'], ); if (edit?.editedType == .manga) { edit!.oldItems.addAll(items); } } if (type == null || type == .characters) { final map = data['characters']; final items = []; for (final c in map['nodes']) { items.add(FavoriteItem.character(c)); } characters = characters.withNext( items, map['pageInfo']['hasNextPage'] ?? false, map['pageInfo']['total'], ); if (edit?.editedType == .characters) { edit!.oldItems.addAll(items); } } if (type == null || type == .staff) { final map = data['staff']; final items = []; for (final s in map['nodes']) { items.add(FavoriteItem.staff(s)); } staff = staff.withNext( items, map['pageInfo']['hasNextPage'] ?? false, map['pageInfo']['total'], ); if (edit?.editedType == .staff) { edit!.oldItems.addAll(items); } } if (type == null || type == .studios) { final map = data['studios']; final items = []; for (final s in map['nodes']) { items.add(FavoriteItem.studio(s)); } studios = studios.withNext( items, map['pageInfo']['hasNextPage'] ?? false, map['pageInfo']['total'], ); if (edit?.editedType == .studios) { edit!.oldItems.addAll(items); } } return Favorites( anime: anime, manga: manga, characters: characters, staff: staff, studios: studios, edit: edit, ); } void startEdit(FavoritesType type) { final value = state.value; if (value == null) return; final edit = FavoritesEdit(type, switch (type) { .anime => [...value.anime.items], .manga => [...value.manga.items], .characters => [...value.characters.items], .staff => [...value.staff.items], .studios => [...value.studios.items], }); state = AsyncValue.data(value.withEdit(edit)); } void cancelEdit() { final value = state.value; if (value == null) return; final edit = value.edit; if (edit == null) return; switch (edit.editedType) { case .anime: value.anime.items.clear(); value.anime.items.addAll(edit.oldItems); case .manga: value.manga.items.clear(); value.manga.items.addAll(edit.oldItems); case .characters: value.characters.items.clear(); value.characters.items.addAll(edit.oldItems); case .staff: value.staff.items.clear(); value.staff.items.addAll(edit.oldItems); case .studios: value.studios.items.clear(); value.studios.items.addAll(edit.oldItems); } state = AsyncValue.data(value.withEdit(null)); } Future saveEdit() async { final value = state.value; if (value == null) return null; final edit = value.edit; if (edit == null) return null; state = AsyncValue.data(value.withEdit(null)); String idsVariableKey; String indexesVariableKey; List items; switch (edit.editedType) { case .anime: idsVariableKey = 'animeIds'; indexesVariableKey = 'animeOrder'; items = value.anime.items; case .manga: idsVariableKey = 'mangaIds'; indexesVariableKey = 'mangaOrder'; items = value.manga.items; case .characters: idsVariableKey = 'characterIds'; indexesVariableKey = 'characterOrder'; items = value.characters.items; case .staff: idsVariableKey = 'staffIds'; indexesVariableKey = 'staffOrder'; items = value.staff.items; case .studios: idsVariableKey = 'studioIds'; indexesVariableKey = 'studioOrder'; items = value.studios.items; } final ids = items.map((e) => e.id).toList(); final indexes = List.generate(items.length, (i) => i + 1, growable: false); final err = await ref.read(repositoryProvider).request(GqlMutation.reorderFavorites, { idsVariableKey: ids, indexesVariableKey: indexes, }).getErrorOrNull(); if (err != null) cancelEdit(); return err; } Future toggleFavorite(int id) async { final edit = state.value?.edit; if (edit == null) return null; final typeKey = switch (edit.editedType) { .anime => 'anime', .manga => 'manga', .characters => 'character', .staff => 'staff', .studios => 'studio', }; return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, { typeKey: id, }).getErrorOrNull(); } } ================================================ FILE: lib/feature/favorites/favorites_view.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/extension/scroll_controller_extension.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/edit/edit_view.dart'; import 'package:otraku/feature/favorites/favorites_model.dart'; import 'package:otraku/feature/favorites/favorites_provider.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/widget/sheets.dart'; class FavoritesView extends ConsumerStatefulWidget { const FavoritesView(this.userId); final int userId; @override ConsumerState createState() => _FavoritesViewState(); } class _FavoritesViewState extends ConsumerState with SingleTickerProviderStateMixin { late final _tabCtrl = TabController(length: FavoritesType.values.length, vsync: this); late final _scrollCtrl = PagedController( loadMore: () => ref .read(favoritesProvider(widget.userId).notifier) .fetch(FavoritesType.values[_tabCtrl.index]), ); @override void initState() { super.initState(); _tabCtrl.addListener(() => setState(() {})); } @override void dispose() { _tabCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final type = FavoritesType.values[_tabCtrl.index]; final isViewer = ref.watch(viewerIdProvider) == widget.userId; final options = ref.watch(persistenceProvider.select((s) => s.options)); final count = ref.watch( favoritesProvider(widget.userId).select((s) => s.value?.getCount(type) ?? 0), ); final onRefresh = (invalidate) => invalidate(favoritesProvider(widget.userId)); final toggleFavorite = (int itemId) => ref.read(favoritesProvider(widget.userId).notifier).toggleFavorite(itemId); final inEditingMode = ref.watch( favoritesProvider(widget.userId).select((s) => s.value?.edit != null), ); return AdaptiveScaffold( topBar: TopBarAnimatedSwitcher( TopBar( key: inEditingMode ? const Key('EditTopBar') : Key('${type.title}TopBar'), title: type.title, trailing: [ if (inEditingMode) ...[ IconButton( tooltip: 'Cancel', icon: const Icon(Icons.close_rounded), onPressed: () => ref.read(favoritesProvider(widget.userId).notifier).cancelEdit(), ), IconButton( tooltip: 'Save', icon: const Icon(Icons.save_outlined), onPressed: () => ref.read(favoritesProvider(widget.userId).notifier).saveEdit().then((err) { if (err == null || !context.mounted) return; SnackBarExtension.show(context, 'Failed to reorder: $err'); }), ), ] else if (count > 0) Padding( padding: const .only(right: Theming.offset), child: Text(count.toString(), style: TextTheme.of(context).titleSmall), ), ], ), ), floatingAction: !isViewer || inEditingMode ? null : HidingFloatingActionButton( key: const Key('edit'), scrollCtrl: _scrollCtrl, child: FloatingActionButton( tooltip: 'Edit', child: const Icon(Icons.edit_outlined), onPressed: () => ref.read(favoritesProvider(widget.userId).notifier).startEdit(type), ), ), navigationConfig: inEditingMode ? null : NavigationConfig( selected: _tabCtrl.index, onChanged: (i) => _tabCtrl.index = i, onSame: (_) => _scrollCtrl.scrollToTop(), items: const { 'Anime': Ionicons.film_outline, 'Manga': Ionicons.book_outline, 'Characters': Ionicons.man_outline, 'Staff': Ionicons.briefcase_outline, 'Studios': Ionicons.business_outline, }, ), child: AnimatedSwitcher( switchInCurve: Curves.easeOut, duration: const Duration(milliseconds: 200), reverseDuration: const Duration(seconds: 0), transitionBuilder: (child, animation) => SlideTransition( position: Tween(begin: const Offset(0, 0.05), end: Offset.zero).animate(animation), child: child, ), child: TabBarView( key: inEditingMode ? const Key('editTabBarView') : const Key('tabBarView'), controller: _tabCtrl, children: [ PagedView( provider: favoritesProvider( widget.userId, ).select((s) => s.unwrapPrevious().whenData((data) => data.anime)), scrollCtrl: _scrollCtrl, onRefresh: onRefresh, onData: (data) { final onTapItem = (FavoriteItem item) => context.push(Routes.media(item.id, item.imageUrl)); final onLongTapItem = (FavoriteItem item) => showSheet(context, EditView((id: item.id, setComplete: false))); return inEditingMode ? _EditList( data.items, onTapItem, onLongTapItem, toggleFavorite, options.highContrast, ) : _ImageGrid(data.items, onTapItem, onLongTapItem, options.highContrast); }, ), PagedView( provider: favoritesProvider( widget.userId, ).select((s) => s.unwrapPrevious().whenData((data) => data.manga)), scrollCtrl: _scrollCtrl, onRefresh: onRefresh, onData: (data) { final onTapItem = (FavoriteItem item) => context.push(Routes.media(item.id, item.imageUrl)); final onLongTapItem = (FavoriteItem item) => showSheet(context, EditView((id: item.id, setComplete: false))); return inEditingMode ? _EditList( data.items, onTapItem, onLongTapItem, toggleFavorite, options.highContrast, ) : _ImageGrid(data.items, onTapItem, onLongTapItem, options.highContrast); }, ), PagedView( provider: favoritesProvider( widget.userId, ).select((s) => s.unwrapPrevious().whenData((data) => data.characters)), scrollCtrl: _scrollCtrl, onRefresh: onRefresh, onData: (data) { final onTapItem = (FavoriteItem item) => context.push(Routes.character(item.id, item.imageUrl)); return inEditingMode ? _EditList(data.items, onTapItem, null, toggleFavorite, options.highContrast) : _ImageGrid(data.items, onTapItem, null, options.highContrast); }, ), PagedView( provider: favoritesProvider( widget.userId, ).select((s) => s.unwrapPrevious().whenData((data) => data.staff)), scrollCtrl: _scrollCtrl, onRefresh: onRefresh, onData: (data) { final onTapItem = (FavoriteItem item) => context.push(Routes.staff(item.id, item.imageUrl)); return inEditingMode ? _EditList(data.items, onTapItem, null, toggleFavorite, options.highContrast) : _ImageGrid(data.items, onTapItem, null, options.highContrast); }, ), PagedView( provider: favoritesProvider( widget.userId, ).select((s) => s.unwrapPrevious().whenData((data) => data.studios)), scrollCtrl: _scrollCtrl, onRefresh: onRefresh, onData: (data) { final onTapItem = (FavoriteItem item) => context.push(Routes.studio(item.id, item.imageUrl)); return inEditingMode ? _EditList( data.items, onTapItem, null, toggleFavorite, options.highContrast, compact: true, ) : _TextGrid(data.items, onTapItem, options.highContrast); }, ), ], ), ), ); } } class _ImageGrid extends StatefulWidget { const _ImageGrid(this.items, this.onTapItem, this.onLongTapItem, this.highContrast); final List items; final void Function(FavoriteItem) onTapItem; final void Function(FavoriteItem)? onLongTapItem; final bool highContrast; @override State<_ImageGrid> createState() => _ImageGridState(); } class _ImageGridState extends State<_ImageGrid> { late List _items; @override void initState() { super.initState(); _items = widget.items.where((e) => e.isFavorite).toList(); } @override void didUpdateWidget(covariant _ImageGrid oldWidget) { super.didUpdateWidget(oldWidget); if (widget.items != oldWidget.items) { _items = widget.items.where((e) => e.isFavorite).toList(); } } @override Widget build(BuildContext context) { final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final textHeight = lineHeight * 2 + 10; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight( minWidth: 100, extraHeight: textHeight, rawHWRatio: Theming.coverHtoWRatio, ), delegate: SliverChildBuilderDelegate( childCount: _items.length, (_, i) => InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => widget.onTapItem(_items[i]), onLongPress: () => widget.onLongTapItem?.call(_items[i]), child: CardExtension.highContrast(widget.highContrast)( child: Column( crossAxisAlignment: .stretch, children: [ if (_items[i].imageUrl != null) Expanded( child: Hero( tag: _items[i].id, child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall), child: CachedImage(_items[i].imageUrl!), ), ), ), SizedBox( height: textHeight, child: Padding( padding: const .all(5), child: Text(_items[i].name, maxLines: 2, overflow: .ellipsis), ), ), ], ), ), ), ), ); } } class _TextGrid extends StatefulWidget { const _TextGrid(this.items, this.onTapItem, this.highContrast); final List items; final void Function(FavoriteItem) onTapItem; final bool highContrast; @override State<_TextGrid> createState() => _TextGridState(); } class _TextGridState extends State<_TextGrid> { late List _items; @override void initState() { super.initState(); _items = widget.items.where((e) => e.isFavorite).toList(); } @override void didUpdateWidget(covariant _TextGrid oldWidget) { super.didUpdateWidget(oldWidget); if (widget.items != oldWidget.items) { _items = widget.items.where((e) => e.isFavorite).toList(); } } @override Widget build(BuildContext context) { final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight( minWidth: 230, height: lineHeight + 20, mainAxisSpacing: 10, crossAxisSpacing: 10, ), delegate: SliverChildBuilderDelegate( childCount: _items.length, (_, i) => InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => widget.onTapItem(_items[i]), child: CardExtension.highContrast(widget.highContrast)( child: Padding( padding: Theming.paddingAll, child: Hero( tag: _items[i].id, child: Text( _items[i].name, style: TextTheme.of(context).bodyMedium, overflow: .ellipsis, maxLines: 1, ), ), ), ), ), ), ); } } class _EditList extends StatefulWidget { const _EditList( this.items, this.onTapItem, this.onLongTapItem, this.toggleFavorite, this.highContrast, { this.compact = false, }); final List items; final void Function(FavoriteItem) onTapItem; final void Function(FavoriteItem)? onLongTapItem; final Future Function(int) toggleFavorite; final bool highContrast; final bool compact; @override State<_EditList> createState() => _EditListState(); } class _EditListState extends State<_EditList> { @override Widget build(BuildContext context) { final lineCount = widget.compact ? 1 : 4; final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final itemExtent = max(lineHeight * lineCount, Theming.iconBig + 20) + 20; return SliverReorderableList( itemExtent: itemExtent, itemCount: widget.items.length, onReorder: (oldIndex, newIndex) => setState(() { if (oldIndex < newIndex) { newIndex -= 1; } final item = widget.items.removeAt(oldIndex); widget.items.insert(newIndex, item); }), proxyDecorator: (child, index, animation) { return DecoratedBox( decoration: BoxDecoration( boxShadow: [ BoxShadow( color: ColorScheme.of(context).surface, blurRadius: 12, spreadRadius: 1, // offset: Offset(0, 4 * animation.value), ), ], ), child: child, ); }, itemBuilder: (context, i) { final item = widget.items[i]; Widget content = Padding( padding: const .only(left: 10, top: 5, bottom: 5), child: Row( spacing: Theming.offset, children: [ Expanded( child: Text(item.name, overflow: .ellipsis, maxLines: lineCount), ), IconButton( icon: item.isFavorite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border_rounded), tooltip: item.isFavorite ? 'Unfavorite' : 'Favorite', onPressed: () async { final isFavorite = item.isFavorite; setState(() => item.isFavorite = !isFavorite); final err = await widget.toggleFavorite(item.id); if (err == null) return; setState(() => item.isFavorite = isFavorite); if (context.mounted) { SnackBarExtension.show(context, err.toString()); } }, ), ReorderableDragStartListener( index: i, child: Padding( padding: Theming.paddingAll, child: Icon(Icons.drag_handle_rounded, size: Theming.iconBig), ), ), ], ), ); if (item.imageUrl != null) { content = Row( children: [ ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: CachedImage(item.imageUrl!, width: itemExtent / Theming.coverHtoWRatio), ), Expanded(child: content), ], ); } return CardExtension.highContrast(widget.highContrast)( key: Key('$i'), margin: const .only(bottom: Theming.offset), child: InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => widget.onTapItem(item), onLongPress: () => widget.onLongTapItem?.call(item), child: content, ), ); }, ); } } ================================================ FILE: lib/feature/feed/feed_floating_action.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/feature/activity/activities_provider.dart'; import 'package:otraku/feature/composition/composition_model.dart'; import 'package:otraku/feature/composition/composition_view.dart'; import 'package:otraku/widget/sheets.dart'; class FeedFloatingAction extends StatelessWidget { const FeedFloatingAction(this.ref) : super(key: const Key('newPost')); final WidgetRef ref; @override Widget build(BuildContext context) { return FloatingActionButton( tooltip: 'New Post', child: const Icon(Icons.edit_outlined), onPressed: () => showSheet( context, CompositionView( tag: const StatusActivityCompositionTag(id: null), onSaved: (map) => ref.read(activitiesProvider(HomeActivitiesTag.instance).notifier).prepend(map), ), ), ); } } ================================================ FILE: lib/feature/feed/feed_top_bar.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/feature/activity/activity_filter_sheet.dart'; import 'package:otraku/feature/settings/settings_provider.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; class FeedTopBarTrailingContent extends StatelessWidget { const FeedTopBarTrailingContent(); @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { final count = ref.watch(settingsProvider.select((s) => s.value?.unreadNotifications ?? 0)); final openNotifications = ref.watch(viewerIdProvider) != null ? () { ref.read(settingsProvider.notifier).clearUnread(); context.push(Routes.notifications); } : () => SnackBarExtension.show(context, 'Log in to view notifications'); Widget notificationIcon = IconButton( tooltip: 'Notifications', icon: const Icon(Ionicons.notifications_outline), onPressed: openNotifications, ); if (count > 0) { notificationIcon = Badge.count( count: count, maxCount: 99, offset: Offset.zero, alignment: Alignment.topLeft, child: notificationIcon, ); } return Row( children: [ IconButton( tooltip: 'Forum', icon: const Icon(Ionicons.chatbubbles_outline), onPressed: () => context.push(Routes.forum), ), notificationIcon, IconButton( tooltip: 'Filter', icon: const Icon(Ionicons.funnel_outline), onPressed: () => showActivityFilterSheet(context, ref, HomeActivitiesTag.instance), ), ], ); }, ); } } ================================================ FILE: lib/feature/forum/forum_filter_model.dart ================================================ import 'package:otraku/extension/iterable_extension.dart'; class ForumFilter { const ForumFilter({ required this.search, required this.category, required this.isSubscribed, required this.sort, }); final String search; final ThreadCategory? category; final bool isSubscribed; final ThreadSort sort; ForumFilter copyWith({ String? search, (ThreadCategory?,)? category, bool? isSubscribed, ThreadSort? sort, }) => ForumFilter( search: search ?? this.search, category: category == null ? this.category : category.$1, isSubscribed: isSubscribed ?? this.isSubscribed, sort: sort ?? this.sort, ); Map toGraphQlVariables() => { if (search.isNotEmpty) 'search': search, if (isSubscribed) 'subscribed': true, if (category != null) 'categoryId': category!.id, if (search.isEmpty) 'sort': sort.value else 'sort': ThreadSort.lastCreated.value, }; } enum ThreadCategory { general('General', 7), anime('Anime', 1), manga('Manga', 2), lightNovels('Light Novels', 3), visualNovels('Visual Novels', 4), gaming('Gaming', 10), music('Music', 9), news('News', 8), releases('Release Discussions', 5), recommendations('Recommendations', 15), forumGames('Forum Games', 16), miscellaneous('Misc', 17), announcements('Site Announcements', 13), feedback('Site Feedback', 11), bugs('Bug Reports', 12), apps('AniList Apps', 18); const ThreadCategory(this.label, this.id); final String label; final int id; static ThreadCategory? from(String? label) => ThreadCategory.values.firstWhereOrNull((v) => v.label == label); } enum ThreadSort { pinned('Pinned', 'IS_STICKY'), firstCreated('First Created', 'CREATED_AT'), lastCreated('Last Created', 'CREATED_AT_DESC'), lastRepliedTo('Last Replied To', 'REPLIED_AT_DESC'); const ThreadSort(this.label, this.value); final String label; final String value; } ================================================ FILE: lib/feature/forum/forum_filter_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/forum/forum_filter_model.dart'; final forumFilterProvider = NotifierProvider.autoDispose( ForumFilterNotifier.new, ); class ForumFilterNotifier extends Notifier { @override ForumFilter build() => const ForumFilter(search: '', category: null, isSubscribed: false, sort: .lastRepliedTo); void update(ForumFilter Function(ForumFilter) callback) => state = callback(state); } ================================================ FILE: lib/feature/forum/forum_filter_view.dart ================================================ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/forum/forum_filter_model.dart'; import 'package:otraku/feature/forum/forum_filter_provider.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/widget/input/stateful_tiles.dart'; import 'package:otraku/widget/sheets.dart'; void showForumFilterSheet(BuildContext context, WidgetRef ref) async { final highContrast = ref.read(persistenceProvider.select((s) => s.options.highContrast)); var filter = ref.read(forumFilterProvider); await showSheet( context, SimpleSheet( initialHeight: Theming.normalTapTarget * 4, builder: (context, scrollCtrl) => ListView( controller: scrollCtrl, padding: const .only(top: Theming.offset), children: [ Padding( padding: const .symmetric(horizontal: Theming.offset), child: ChipSelector.ensureSelected( title: 'Sort', items: ThreadSort.values.map((v) => (v.label, v)).toList(), value: filter.sort, onChanged: (v) => filter = filter.copyWith(sort: v), highContrast: highContrast, ), ), Padding( padding: const .symmetric(horizontal: Theming.offset), child: ChipSelector( title: 'Category', items: ThreadCategory.values.map((v) => (v.label, v)).toList(), value: filter.category, onChanged: (v) => filter = filter.copyWith(category: (v,)), highContrast: highContrast, ), ), StatefulSwitchListTile( title: const Text('Subscribed'), value: filter.isSubscribed, onChanged: (v) => filter = filter.copyWith(isSubscribed: v), ), ], ), ), ); ref.read(forumFilterProvider.notifier).update((_) => filter); } ================================================ FILE: lib/feature/forum/forum_model.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; class ThreadItem { const ThreadItem._({ required this.id, required this.title, required this.viewCount, required this.replyCount, required this.likeCount, required this.isSubscribed, required this.isPinned, required this.isLocked, required this.userId, required this.userName, required this.userAvatar, required this.userTimestamp, required this.isUserReplying, required this.topics, }); factory ThreadItem(Map map) { final topics = []; for (final c in map['categories'] ?? const []) { topics.add(c['name']); } for (final c in map['mediaCategories'] ?? const []) { topics.add(c['title']?['userPreferred'] ?? '?'); } final ( int userId, String userName, String userAvatar, DateTime userTimestamp, bool isUserReplying, ) = map['repliedAt'] != null ? ( map['replyUser']?['id'] ?? 0, map['replyUser']?['name'] ?? '?', map['replyUser']?['avatar']?['large'] ?? '', DateTimeExtension.fromSecondsSinceEpoch(map['repliedAt']), true, ) : ( map['user']?['id'] ?? 0, map['user']?['name'] ?? '?', map['user']?['avatar']?['large'] ?? '', DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']), false, ); return ThreadItem._( id: map['id'], title: map['title'] ?? '?', viewCount: map['viewCount'] ?? 0, replyCount: map['replyCount'] ?? 0, likeCount: map['likeCount'] ?? 0, isSubscribed: map['isSubscribed'] ?? false, isPinned: map['isSticky'] ?? false, isLocked: map['isLocked'] ?? false, userId: userId, userName: userName, userAvatar: userAvatar, userTimestamp: userTimestamp, isUserReplying: isUserReplying, topics: topics, ); } final int id; final String title; final int viewCount; final int replyCount; final int likeCount; final bool isSubscribed; final bool isPinned; final bool isLocked; final int userId; final String userName; final String userAvatar; final DateTime userTimestamp; final bool isUserReplying; final List topics; } ================================================ FILE: lib/feature/forum/forum_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/forum/forum_filter_model.dart'; import 'package:otraku/feature/forum/forum_filter_provider.dart'; import 'package:otraku/feature/forum/forum_model.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; import 'package:otraku/util/paged.dart'; final forumProvider = AsyncNotifierProvider.autoDispose>( ForumNotifier.new, ); class ForumNotifier extends AsyncNotifier> { late ForumFilter _filter; @override FutureOr> build() { _filter = ref.watch(forumFilterProvider); return _fetch(const Paged()); } Future fetch() async { final oldState = state.value ?? const Paged(); if (!oldState.hasNext) return; state = await AsyncValue.guard(() => _fetch(oldState)); } Future> _fetch(Paged oldState) async { final data = await ref.read(repositoryProvider).request(GqlQuery.threadPage, { 'page': oldState.next, ..._filter.toGraphQlVariables(), }); final items = []; for (final t in data['Page']['threads']) { items.add(ThreadItem(t)); } return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false); } } ================================================ FILE: lib/feature/forum/forum_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/forum/forum_filter_provider.dart'; import 'package:otraku/feature/forum/forum_filter_view.dart'; import 'package:otraku/feature/forum/forum_provider.dart'; import 'package:otraku/feature/forum/thread_item_list.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/debounce.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/widget/input/search_field.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/paged_view.dart'; class ForumView extends ConsumerStatefulWidget { const ForumView(); @override ConsumerState createState() => _ForumViewState(); } class _ForumViewState extends ConsumerState { late final _scrollCtrl = PagedController( loadMore: () => ref.read(forumProvider.notifier).fetch(), ); @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final options = ref.watch(persistenceProvider.select((s) => s.options)); return AdaptiveScaffold( topBar: TopBar( trailing: [ Consumer( builder: (context, ref, filterButton) { return Expanded( child: Row( children: [ Expanded( child: SearchField( debounce: Debounce(), hint: 'Forum', value: ref.watch(forumFilterProvider.select((s) => s.search)), onChanged: (search) => ref .read(forumFilterProvider.notifier) .update((s) => s.copyWith(search: search.trim())), ), ), filterButton!, ], ), ); }, child: IconButton( tooltip: 'Filter', icon: const Icon(Ionicons.funnel_outline), onPressed: () => showForumFilterSheet(context, ref), ), ), ], ), child: PagedView( provider: forumProvider, scrollCtrl: _scrollCtrl, onRefresh: (invalidate) => invalidate(forumProvider), onData: (data) => ThreadItemList(data.items, options.highContrast, options.analogClock), ), ); } } ================================================ FILE: lib/feature/forum/thread_item_list.dart ================================================ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/forum/forum_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/text_rail.dart'; import 'package:otraku/widget/timestamp.dart'; class ThreadItemList extends StatelessWidget { const ThreadItemList(this.items, this.highContrast, this.analogClock); final List items; final bool highContrast; final bool analogClock; @override Widget build(BuildContext context) { return SliverList.builder( itemCount: items.length, itemBuilder: (context, i) { final item = items[i]; return Padding( padding: const .only(bottom: Theming.offset), child: Column( mainAxisSize: .min, crossAxisAlignment: .start, spacing: Theming.offset, children: [ Row( spacing: Theming.offset, children: [ GestureDetector( onTap: () => context.push(Routes.user(item.userId, item.userAvatar)), child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage(item.userAvatar, height: 50, width: 50), ), ), Expanded( child: OverflowBar( spacing: 5, overflowSpacing: 5, children: [ Text(item.userName, overflow: .ellipsis, maxLines: 1), Timestamp( item.userTimestamp, analogClock, leading: Text( item.isUserReplying ? 'replied' : 'posted', style: TextTheme.of(context).labelSmall, ), ), ], ), ), ], ), CardExtension.highContrast(highContrast)( child: InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => context.push(Routes.thread(item.id)), child: Padding( padding: Theming.paddingAll, child: Column( spacing: Theming.offset, mainAxisSize: .min, crossAxisAlignment: .start, children: [ Text(item.title), TextRail({for (final topic in item.topics) topic: false}), Row( spacing: Theming.offset, children: [ if (item.isPinned) Tooltip( message: 'Pinned', triggerMode: .tap, child: Icon(Icons.push_pin_outlined, size: Theming.iconSmall), ), if (item.isLocked) Tooltip( message: 'Locked', triggerMode: .tap, child: Icon(Icons.lock_outline_rounded, size: Theming.iconSmall), ), const Spacer(), _buildInfoIcon( context, 'Views', item.viewCount.toString(), Icons.remove_red_eye_outlined, ), _buildInfoIcon( context, 'Replies', item.replyCount.toString(), Icons.reply_rounded, ), _buildInfoIcon( context, 'Likes', item.likeCount.toString(), Icons.favorite_outline_rounded, ), ], ), ], ), ), ), ), ], ), ); }, ); } Widget _buildInfoIcon(BuildContext context, String label, String value, IconData icon) => Tooltip( message: label, triggerMode: .tap, child: Row( mainAxisSize: .min, spacing: 5, children: [ Text(value, style: Theme.of(context).textTheme.labelSmall), Icon(icon, size: Theming.iconSmall), ], ), ); } ================================================ FILE: lib/feature/home/home_model.dart ================================================ class Home { const Home({required this.didExpandAnimeCollection, required this.didExpandMangaCollection}); /// In preview mode, user's collections first load only current media. /// The rest is loaded by a manual request from the user /// and thus the collection "expands". /// If preview mode is off, collections are auto-expanded /// and immediately load everything. final bool didExpandAnimeCollection; final bool didExpandMangaCollection; Home withExpandedCollection(bool ofAnime) => ofAnime ? Home(didExpandAnimeCollection: true, didExpandMangaCollection: didExpandMangaCollection) : Home(didExpandAnimeCollection: didExpandAnimeCollection, didExpandMangaCollection: true); } enum HomeTab { feed('Feed'), anime('Anime'), manga('Manga'), discover('Discover'), profile('Profile'); const HomeTab(this.label); final String label; } ================================================ FILE: lib/feature/home/home_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/home/home_model.dart'; final homeProvider = NotifierProvider.autoDispose(HomeNotifier.new); class HomeNotifier extends Notifier { @override Home build() { final options = ref.watch(persistenceProvider.select((s) => s.options)); return switch (stateOrNull) { Home oldState => oldState, null => Home( didExpandAnimeCollection: !options.animeCollectionPreview, didExpandMangaCollection: !options.mangaCollectionPreview, ), }; } void expandCollection(bool ofAnime) => state = state.withExpandedCollection(ofAnime); } ================================================ FILE: lib/feature/home/home_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/scroll_controller_extension.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/feature/activity/activities_provider.dart'; import 'package:otraku/feature/activity/activities_view.dart'; import 'package:otraku/feature/collection/collection_entries_provider.dart'; import 'package:otraku/feature/collection/collection_floating_action.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/collection/collection_top_bar.dart'; import 'package:otraku/feature/discover/discover_floating_action.dart'; import 'package:otraku/feature/discover/discover_provider.dart'; import 'package:otraku/feature/discover/discover_top_bar.dart'; import 'package:otraku/feature/feed/feed_floating_action.dart'; import 'package:otraku/feature/feed/feed_top_bar.dart'; import 'package:otraku/feature/home/home_model.dart'; import 'package:otraku/feature/home/home_provider.dart'; import 'package:otraku/feature/settings/settings_provider.dart'; import 'package:otraku/feature/tag/tag_provider.dart'; import 'package:otraku/feature/user/user_providers.dart'; import 'package:otraku/feature/user/user_view.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/feature/discover/discover_view.dart'; import 'package:otraku/feature/collection/collection_view.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/top_bar.dart'; class HomeView extends ConsumerStatefulWidget { const HomeView({super.key, this.tab}); final HomeTab? tab; @override ConsumerState createState() => _HomeViewState(); } class _HomeViewState extends ConsumerState with SingleTickerProviderStateMixin { final _animeFocusNode = FocusNode(); final _mangaFocusNode = FocusNode(); final _discoverFocusNode = FocusNode(); final _animeScrollCtrl = ScrollController(); final _mangaScrollCtrl = ScrollController(); late final _feedScrollCtrl = PagedController( loadMore: () => ref.read(activitiesProvider(HomeActivitiesTag.instance).notifier).fetch(), ); late final _discoverScrollCtrl = PagedController( loadMore: () => ref.read(discoverProvider.notifier).fetch(), ); late final _tabCtrl = TabController(length: HomeTab.values.length, vsync: this); @override void initState() { super.initState(); final persistence = ref.read(persistenceProvider); _tabCtrl.index = persistence.options.homeTab.index; if (widget.tab != null) _tabCtrl.index = widget.tab!.index; _tabCtrl.addListener( () => WidgetsBinding.instance.addPostFrameCallback((_) { final tab = HomeTab.values[_tabCtrl.index]; if (tab != .anime) _animeFocusNode.unfocus(); if (tab != .manga) _mangaFocusNode.unfocus(); if (tab != .discover) _discoverFocusNode.unfocus(); context.go(Routes.home(tab)); }), ); } @override void didUpdateWidget(covariant HomeView oldWidget) { super.didUpdateWidget(oldWidget); if (widget.tab != null) _tabCtrl.index = widget.tab!.index; } @override void dispose() { ref.invalidate(discoverProvider); ref.invalidate(activitiesProvider(HomeActivitiesTag.instance)); _animeFocusNode.dispose(); _mangaFocusNode.dispose(); _discoverFocusNode.dispose(); _animeScrollCtrl.dispose(); _mangaScrollCtrl.dispose(); _feedScrollCtrl.dispose(); _discoverScrollCtrl.dispose(); _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { ref.watch(settingsProvider.select((_) => null)); ref.watch(tagsProvider.select((_) => null)); UserTag? userTag; CollectionTag? animeCollectionTag; CollectionTag? mangaCollectionTag; final viewerId = ref.watch(viewerIdProvider); if (viewerId != null) { userTag = idUserTag(viewerId); animeCollectionTag = (userId: viewerId, ofAnime: true); mangaCollectionTag = (userId: viewerId, ofAnime: false); ref.watch(userProvider(userTag).select((_) => null)); ref.watch(collectionEntriesProvider(animeCollectionTag).select((_) => null)); ref.watch(collectionEntriesProvider(mangaCollectionTag).select((_) => null)); } final home = ref.watch(homeProvider); final primaryScrollCtrl = PrimaryScrollController.of(context); final formFactor = Theming.of(context).formFactor; final topBar = TopBarAnimatedSwitcher(switch (_tabCtrl.index) { 0 => const TopBar( key: Key('feedTopBar'), title: 'Feed', trailing: [FeedTopBarTrailingContent()], ), 1 when animeCollectionTag != null => TopBar( key: const Key('animeCollectionTopBar'), trailing: [CollectionTopBarTrailingContent(animeCollectionTag, _animeFocusNode)], ), 2 when mangaCollectionTag != null => TopBar( key: const Key('mangaCollectionTopBar'), trailing: [CollectionTopBarTrailingContent(mangaCollectionTag, _mangaFocusNode)], ), 3 => TopBar( key: const Key('discoverTobBar'), trailing: [DiscoverTopBarTrailingContent(_discoverFocusNode)], ), _ => const EmptyTopBar() as PreferredSizeWidget, }); final navigationConfig = NavigationConfig( items: _homeTabs, selected: _tabCtrl.index, onChanged: (i) => context.go(Routes.home(HomeTab.values[i])), onSame: (i) { final tab = HomeTab.values[i]; switch (tab) { case .feed: _feedScrollCtrl.scrollToTop(); case .anime: if (_animeScrollCtrl.position.pixels > 0) { _animeScrollCtrl.scrollToTop(); return; } _toggleSearchFocus(_animeFocusNode); case .manga: if (_mangaScrollCtrl.position.pixels > 0) { _mangaScrollCtrl.scrollToTop(); return; } _toggleSearchFocus(_mangaFocusNode); case .discover: if (_discoverScrollCtrl.position.pixels > 0) { _discoverScrollCtrl.scrollToTop(); return; } _toggleSearchFocus(_discoverFocusNode); return; case .profile: if (primaryScrollCtrl.positions.last.pixels > 0) { primaryScrollCtrl.scrollToTop(); return; } context.push(Routes.settings); } }, ); final floatingAction = switch (_tabCtrl.index) { 0 => HidingFloatingActionButton( key: const Key('feed'), scrollCtrl: _feedScrollCtrl, child: FeedFloatingAction(ref), ), 1 => (formFactor == .phone || !home.didExpandAnimeCollection) && animeCollectionTag != null ? HidingFloatingActionButton( key: const Key('anime'), scrollCtrl: _animeScrollCtrl, child: CollectionFloatingAction(animeCollectionTag), ) : null, 2 => (formFactor == .phone || !home.didExpandMangaCollection) && mangaCollectionTag != null ? HidingFloatingActionButton( key: const Key('manga'), scrollCtrl: _mangaScrollCtrl, child: CollectionFloatingAction(mangaCollectionTag), ) : null, 3 => formFactor == .phone ? HidingFloatingActionButton( key: const Key('discover'), scrollCtrl: _discoverScrollCtrl, child: const DiscoverFloatingAction(), ) : null, _ => null, }; final child = TabBarView( controller: _tabCtrl, children: [ ActivitiesSubView(HomeActivitiesTag.instance, _feedScrollCtrl), CollectionSubview( scrollCtrl: _animeScrollCtrl, tag: animeCollectionTag, formFactor: formFactor, key: Key(true.toString()), ), CollectionSubview( scrollCtrl: _mangaScrollCtrl, tag: mangaCollectionTag, formFactor: formFactor, key: Key(false.toString()), ), DiscoverSubview(_discoverScrollCtrl, formFactor), UserHomeView( userTag, null, homeScrollCtrl: primaryScrollCtrl, removableTopPadding: topBar.preferredSize.height, ), ], ); return AdaptiveScaffold( topBar: topBar, floatingAction: floatingAction, navigationConfig: navigationConfig, child: child, ); } static final _homeTabs = { HomeTab.feed.label: Ionicons.file_tray_outline, HomeTab.anime.label: Ionicons.film_outline, HomeTab.manga.label: Ionicons.book_outline, HomeTab.discover.label: Ionicons.compass_outline, HomeTab.profile.label: Ionicons.person_outline, }; void _toggleSearchFocus(FocusNode node) => node.hasFocus ? node.unfocus() : node.requestFocus(); } ================================================ FILE: lib/feature/media/media_activities_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/activity/activities_filter_model.dart'; import 'package:otraku/feature/activity/activities_filter_provider.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/feature/activity/activities_provider.dart'; import 'package:otraku/feature/activity/activity_card.dart'; import 'package:otraku/feature/activity/activity_model.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/paged_view.dart'; class MediaActivitiesSubview extends StatelessWidget { const MediaActivitiesSubview({ required this.ref, required this.tag, required this.scrollCtrl, required this.viewerId, required this.options, }); final WidgetRef ref; final MediaActivitiesTag tag; final ScrollController scrollCtrl; final int? viewerId; final Options options; @override Widget build(BuildContext context) { return PagedView( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(activitiesProvider(tag)), provider: activitiesProvider(tag), header: _FollowingFilterButton(ref, tag), onData: (data) => SliverList( delegate: SliverChildBuilderDelegate( childCount: data.items.length, (context, i) => ActivityCard( withHeader: true, analogClock: options.analogClock, highContrast: options.highContrast, activity: data.items[i], footer: ActivityFooter( viewerId: viewerId, activity: data.items[i], toggleLike: () => ref.read(activitiesProvider(tag).notifier).toggleLike(data.items[i]), toggleSubscription: () => ref.read(activitiesProvider(tag).notifier).toggleSubscription(data.items[i]), togglePin: () => ref.read(activitiesProvider(tag).notifier).togglePin(data.items[i]), remove: () => ref.read(activitiesProvider(tag).notifier).remove(data.items[i]), onEdited: (map) { final activity = Activity.maybe(map, viewerId, options.imageQuality); if (activity == null) return; ref.read(activitiesProvider(tag).notifier).replace(activity); }, reply: () => context.push(Routes.activity(data.items[i].id, null)), ), ), ), ), ); } } class _FollowingFilterButton extends StatelessWidget { const _FollowingFilterButton(this.ref, this.tag); final WidgetRef ref; final MediaActivitiesTag tag; @override Widget build(BuildContext context) { final filter = ref.watch(activitiesFilterProvider(tag)); return SliverToBoxAdapter( child: SizedBox( height: Theming.normalTapTarget, child: switch (filter) { MediaActivitiesFilter filter => Row( spacing: Theming.offset, children: [ FilterChip( label: const Text("Global"), selected: filter.socialGroup == .global, onSelected: (val) => ref.read(activitiesFilterProvider(tag).notifier).state = filter .copyWith(socialGroup: .global), ), FilterChip( label: const Text("Following"), selected: filter.socialGroup == .followed, onSelected: (val) => ref.read(activitiesFilterProvider(tag).notifier).state = filter .copyWith(socialGroup: .followed), ), FilterChip( label: const Text("Self"), selected: filter.socialGroup == .self, onSelected: (val) => ref.read(activitiesFilterProvider(tag).notifier).state = filter .copyWith(socialGroup: .self), ), ], ), _ => const SizedBox.shrink(), }, ), ); } } ================================================ FILE: lib/feature/media/media_characters_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/grid/dual_relation_grid.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/feature/media/media_provider.dart'; import 'package:otraku/widget/shadowed_overflow_list.dart'; class MediaCharactersSubview extends StatelessWidget { const MediaCharactersSubview({ required this.id, required this.scrollCtrl, required this.highContrast, }); final int id; final ScrollController scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { return PagedView<(MediaRelatedItem, MediaRelatedItem?)>( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(mediaConnectionsProvider(id)), provider: mediaConnectionsProvider( id, ).select((s) => s.unwrapPrevious().whenData((data) => data.getCharactersAndVoiceActors())), onData: (data) { return SliverMainAxisGroup( slivers: [ _LanguageSelector(id), DualRelationGrid( items: data.items, onTapPrimary: (item) => context.push(Routes.character(item.tileId, item.tileImageUrl)), onTapSecondary: (item) => context.push(Routes.staff(item.tileId, item.tileImageUrl)), highContrast: highContrast, ), ], ); }, ); } } class _LanguageSelector extends StatelessWidget { const _LanguageSelector(this.id); final int id; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, child) { final selection = ref.watch( mediaConnectionsProvider(id).select((s) { final value = s.value; if (value == null) return null; return (value.languageToVoiceActors, value.selectedLanguage); }), ); if (selection == null) return const SliverToBoxAdapter(); final languageMappings = selection.$1; final selectedLanguage = selection.$2; if (languageMappings.length < 2) return const SliverToBoxAdapter(); return SliverToBoxAdapter( child: SizedBox( height: Theming.normalTapTarget, child: ShadowedOverflowList( itemCount: languageMappings.length, itemBuilder: (context, i) => FilterChip( label: Text(languageMappings[i].language), selected: i == selectedLanguage, onSelected: (selected) { if (!selected) return; ref.read(mediaConnectionsProvider(id).notifier).changeLanguage(i); }, ), ), ), ); }, ); } } ================================================ FILE: lib/feature/media/media_floating_actions.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/feature/edit/edit_view.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/widget/sheets.dart'; class MediaEditButton extends StatefulWidget { const MediaEditButton(this.media); final Media media; @override State createState() => _MediaEditButtonState(); } class _MediaEditButtonState extends State { @override Widget build(BuildContext context) { final media = widget.media; return FloatingActionButton( tooltip: media.entryEdit.listStatus == null ? 'Add' : 'Edit', child: media.entryEdit.listStatus == null ? const Icon(Icons.add) : const Icon(Icons.edit_outlined), onPressed: () => showSheet( context, EditView(( id: media.info.id, setComplete: false, ), callback: (entryEdit) => setState(() => media.entryEdit = entryEdit)), ), ); } } ================================================ FILE: lib/feature/media/media_following_view.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/input/note_label.dart'; import 'package:otraku/widget/input/score_label.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/media/media_provider.dart'; class MediaFollowingSubview extends StatelessWidget { const MediaFollowingSubview({ required this.id, required this.scrollCtrl, required this.highContrast, }); final int id; final ScrollController scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { return PagedView( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(mediaFollowingProvider(id)), provider: mediaFollowingProvider(id), onData: (data) => _MediaFollowingGrid(data.items, highContrast), ); } } class _MediaFollowingGrid extends StatelessWidget { const _MediaFollowingGrid(this.items, this.highContrast); final List items; final bool highContrast; @override Widget build(BuildContext context) { final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final tileHeight = bodyMediumLineHeight + max(bodyMediumLineHeight, 35) + 5; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 300, height: tileHeight), delegate: SliverChildBuilderDelegate( childCount: items.length, (context, i) => GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.user(items[i].userId, items[i].userAvatar)), child: CardExtension.highContrast(highContrast)( child: Row( children: [ Hero( tag: items[i].userId, child: ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: CachedImage(items[i].userAvatar, width: tileHeight), ), ), Expanded( child: Padding( padding: const .only(top: 5, left: Theming.offset, right: Theming.offset), child: Column( mainAxisAlignment: .spaceBetween, crossAxisAlignment: .start, children: [ Text(items[i].userName, overflow: .ellipsis, maxLines: 1), SizedBox( height: 35, child: Row( mainAxisAlignment: .spaceBetween, children: [ Flexible( child: Text( items[i].entryStatus.label(null), overflow: .ellipsis, maxLines: 1, ), ), NotesLabel(items[i].notes), ScoreLabel(items[i].score, items[i].scoreFormat), ], ), ), ], ), ), ), ], ), ), ), ), ); } } ================================================ FILE: lib/feature/media/media_header.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/content_header.dart'; import 'package:otraku/widget/text_rail.dart'; class MediaHeader extends StatelessWidget { const MediaHeader.withTabBar({ required this.id, required this.coverUrl, required this.media, required TabController this.tabCtrl, required void Function() this.scrollToTop, required this.toggleFavorite, }); const MediaHeader.withoutTabBar({ required this.id, required this.coverUrl, required this.media, required this.toggleFavorite, }) : tabCtrl = null, scrollToTop = null; final int id; final String? coverUrl; final Media? media; final TabController? tabCtrl; final void Function()? scrollToTop; final Future Function() toggleFavorite; @override Widget build(BuildContext context) { final textRailItems = {}; if (media != null) { final info = media!.info; if (info.isAdult) textRailItems['Adult'] = true; if (info.format != null) { textRailItems[info.format!.label] = false; } if (media!.entryEdit.listStatus != null) { textRailItems[media!.entryEdit.listStatus!.label(info.isAnime)] = false; } if (info.airingAt != null) { textRailItems['Ep ${info.nextEpisode} in ' '${info.airingAt!.timeUntil}'] = true; } if (media!.entryEdit.listStatus != null) { final progress = media!.entryEdit.progress; if (info.nextEpisode != null && info.nextEpisode! - 1 > progress) { textRailItems['${info.nextEpisode! - 1 - progress}' ' ep behind'] = true; } } } return ContentHeader( bannerUrl: media?.info.banner, imageUrl: media?.info.cover ?? coverUrl, imageLargeUrl: media?.info.extraLargeCover, imageHeightToWidthRatio: Theming.coverHtoWRatio, imageHeroTag: id, siteUrl: media?.info.siteUrl, title: media?.info.preferredTitle, details: [TextRail(textRailItems, style: TextTheme.of(context).labelMedium)], tabBarConfig: tabCtrl != null && scrollToTop != null ? (tabCtrl: tabCtrl!, scrollToTop: scrollToTop!, tabs: tabsWithOverview) : null, trailingTopButtons: [if (media != null) _FavoriteButton(media!.info, toggleFavorite)], ); } static const tabsWithoutOverview = [ Tab(text: 'Related'), Tab(text: 'Characters'), Tab(text: 'Staff'), Tab(text: 'Reviews'), Tab(text: 'Threads'), Tab(text: 'Following'), Tab(text: 'Activities'), Tab(text: 'Recommendations'), Tab(text: 'Statistics'), ]; static const tabsWithOverview = [Tab(text: 'Overview'), ...tabsWithoutOverview]; } class _FavoriteButton extends StatefulWidget { const _FavoriteButton(this.info, this.toggleFavorite); final MediaInfo info; final Future Function() toggleFavorite; @override State<_FavoriteButton> createState() => __FavoriteButtonState(); } class __FavoriteButtonState extends State<_FavoriteButton> { @override Widget build(BuildContext context) { final info = widget.info; return IconButton( tooltip: info.isFavorite ? 'Unfavourite' : 'Favourite', icon: info.isFavorite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border), onPressed: () async { setState(() => info.isFavorite = !info.isFavorite); final err = await widget.toggleFavorite(); if (err == null) return; setState(() => info.isFavorite = !info.isFavorite); if (context.mounted) SnackBarExtension.show(context, err.toString()); }, ); } } ================================================ FILE: lib/feature/media/media_item_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/feature/media/media_item_model.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; class MediaItemGrid extends StatelessWidget { const MediaItemGrid(this.items); final List items; @override Widget build(BuildContext context) { return SliverGrid( gridDelegate: const SliverGridDelegateWithMinWidthAndExtraHeight( minWidth: 100, extraHeight: 40, rawHWRatio: Theming.coverHtoWRatio, ), delegate: SliverChildBuilderDelegate((_, i) => _Tile(items[i]), childCount: items.length), ); } } class _Tile extends StatelessWidget { const _Tile(this.item); final MediaItem item; @override Widget build(BuildContext context) { return MediaRouteTile( id: item.id, imageUrl: item.imageUrl, child: Column( children: [ Expanded( child: Hero( tag: item.id, child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage(item.imageUrl), ), ), ), const SizedBox(height: 5), SizedBox( height: 35, child: Text( item.name, maxLines: 2, overflow: .fade, style: TextTheme.of(context).bodyMedium, ), ), ], ), ); } } ================================================ FILE: lib/feature/media/media_item_model.dart ================================================ import 'package:otraku/feature/viewer/persistence_model.dart'; class MediaItem { const MediaItem._({required this.id, required this.name, required this.imageUrl}); factory MediaItem(Map map, ImageQuality imageQuality) => MediaItem._( id: map['id'], name: map['title']['userPreferred'], imageUrl: map['coverImage'][imageQuality.value], ); final int id; final String name; final String imageUrl; } ================================================ FILE: lib/feature/media/media_models.dart ================================================ import 'package:flutter/widgets.dart'; import 'package:otraku/extension/color_extension.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/extension/iterable_extension.dart'; import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/feature/edit/edit_model.dart'; import 'package:otraku/feature/tag/tag_model.dart'; import 'package:otraku/util/tile_modelable.dart'; class Media { Media(this.entryEdit, this.info, this.stats, this.related); EntryEdit entryEdit; final MediaInfo info; final MediaStats stats; final List related; } class MediaConnections { const MediaConnections({ this.characters = const Paged(), this.staff = const Paged(), this.reviews = const Paged(), this.recommendations = const Paged(), this.languageToVoiceActors = const [], this.selectedLanguage = 0, }); final Paged characters; final Paged staff; final Paged reviews; final Paged recommendations; /// For each language, a list of voice actors /// is mapped to the corresponding media's id. final List languageToVoiceActors; final int selectedLanguage; /// Returns the characters, along with their voice actors, /// corresponding to the current [language]. If there are /// multiple actors, the given character is repeated for each actor. Paged<(MediaRelatedItem, MediaRelatedItem?)> getCharactersAndVoiceActors() { if (languageToVoiceActors.isEmpty) { return Paged( items: characters.items.map((c) => (c, null)).toList(), hasNext: characters.hasNext, next: characters.next, ); } final actorsPerMedia = languageToVoiceActors[selectedLanguage].voiceActors; final charactersAndVoiceActors = <(MediaRelatedItem, MediaRelatedItem?)>[]; for (final c in characters.items) { final actors = actorsPerMedia[c.id]; if (actors == null || actors.isEmpty) { charactersAndVoiceActors.add((c, null)); continue; } for (final va in actors) { charactersAndVoiceActors.add((c, va)); } } return Paged( items: charactersAndVoiceActors, hasNext: characters.hasNext, next: characters.next, ); } MediaConnections copyWith({ Paged? characters, Paged? staff, Paged? reviews, Paged? recommendations, List? languageToVoiceActors, int? selectedLanguage, }) => MediaConnections( characters: characters ?? this.characters, staff: staff ?? this.staff, reviews: reviews ?? this.reviews, recommendations: recommendations ?? this.recommendations, languageToVoiceActors: languageToVoiceActors ?? this.languageToVoiceActors, selectedLanguage: selectedLanguage ?? this.selectedLanguage, ); } typedef MediaLanguageMapping = ({String language, Map> voiceActors}); class RelatedMedia { const RelatedMedia._({ required this.id, required this.isAnime, required this.title, required this.imageUrl, required this.relationType, required this.format, required this.entryStatus, required this.releaseStatus, }); factory RelatedMedia(Map map, ImageQuality imageQuality) => RelatedMedia._( id: map['node']['id'], title: map['node']['title']['userPreferred'], imageUrl: map['node']['coverImage'][imageQuality.value], relationType: StringExtension.tryNoScreamingSnakeCase(map['relationType']), format: MediaFormat.from(map['node']['format']), entryStatus: ListStatus.from(map['node']['mediaListEntry']?['status']), releaseStatus: StringExtension.tryNoScreamingSnakeCase(map['node']['status']), isAnime: map['node']['type'] == 'ANIME', ); final int id; final bool isAnime; final String title; final String imageUrl; final String? relationType; final MediaFormat? format; final ListStatus? entryStatus; final String? releaseStatus; } class MediaRelatedItem implements TileModelable { const MediaRelatedItem._({ required this.id, required this.name, required this.imageUrl, required this.role, }); factory MediaRelatedItem(Map map, String? role) => MediaRelatedItem._( id: map['id'], name: map['name']['userPreferred'], imageUrl: map['image']['large'], role: role, ); final int id; final String name; final String imageUrl; final String? role; @override int get tileId => id; @override String get tileTitle => name; @override String? get tileSubtitle => role; @override String get tileImageUrl => imageUrl; } class RelatedReview { const RelatedReview._({ required this.reviewId, required this.userId, required this.avatar, required this.username, required this.summary, required this.rating, required this.score, }); static RelatedReview? maybe(Map map) { if (map['user'] == null) return null; return RelatedReview._( reviewId: map['id'], userId: map['user']['id'], username: map['user']['name'] ?? '', summary: map['summary'] ?? '', avatar: map['user']['avatar']['large'], rating: '${map['rating']}/${map['ratingAmount']}', score: map['score'] ?? 0, ); } final int reviewId; final int userId; final String username; final String avatar; final String summary; final String rating; final int score; } class MediaFollowing { MediaFollowing._({ required this.entryStatus, required this.score, required this.notes, required this.userId, required this.userName, required this.userAvatar, required this.scoreFormat, }); factory MediaFollowing(Map map) => MediaFollowing._( entryStatus: ListStatus.from(map['status'])!, score: (map['score'] ?? 0).toDouble(), notes: map['notes'] ?? '', userId: map['user']['id'], userName: map['user']['name'], userAvatar: map['user']['avatar']['large'], scoreFormat: ScoreFormat.from(map['user']['mediaListOptions']?['scoreFormat']), ); final ListStatus entryStatus; final double score; final String notes; final int userId; final String userName; final String userAvatar; final ScoreFormat scoreFormat; } class Recommendation { Recommendation._({ required this.id, required this.rating, required this.userRating, required this.title, required this.imageUrl, required this.isAnime, required this.releaseYear, required this.format, required this.entryStatus, }); factory Recommendation(Map map, ImageQuality imageQuality) { final userRating = map['userRating'] == 'RATE_UP' ? true : map['userRating'] == 'RATE_DOWN' ? false : null; return Recommendation._( id: map['mediaRecommendation']['id'], rating: map['rating'] ?? 0, userRating: userRating, title: map['mediaRecommendation']['title']['userPreferred'], imageUrl: map['mediaRecommendation']['coverImage'][imageQuality.value], isAnime: map['mediaRecommendation']['type'] == 'ANIME', releaseYear: map['mediaRecommendation']['startDate']?['year'], format: MediaFormat.from(map['mediaRecommendation']['format']), entryStatus: ListStatus.from(map['mediaRecommendation']['mediaListEntry']?['status']), ); } final int id; int rating; bool? userRating; final String title; final String imageUrl; final bool isAnime; final int? releaseYear; final MediaFormat? format; final ListStatus? entryStatus; } class MediaInfo { MediaInfo._({ required this.id, required this.isAnime, required this.preferredTitle, required this.romajiTitle, required this.englishTitle, required this.nativeTitle, required this.synonyms, required this.cover, required this.extraLargeCover, required this.banner, required this.description, required this.format, required this.status, required this.nextEpisode, required this.airingAt, required this.episodes, required this.duration, required this.chapters, required this.volumes, required this.startDate, required this.endDate, required this.season, required this.averageScore, required this.meanScore, required this.popularity, required this.favourites, required this.isFavorite, required this.genres, required this.source, required this.hashtag, required this.siteUrl, required this.countryOfOrigin, required this.isAdult, }); final int id; final bool isAnime; final String? preferredTitle; final String? romajiTitle; final String? englishTitle; final String? nativeTitle; final List synonyms; final String description; final String cover; final String extraLargeCover; final String? banner; final MediaFormat? format; final ReleaseStatus? status; final int? nextEpisode; final DateTime? airingAt; final int? episodes; final String? duration; final int? chapters; final int? volumes; final String? startDate; final String? endDate; final String? season; final int averageScore; final int meanScore; final int popularity; final int favourites; bool isFavorite; final List genres; final studios = {}; final producers = {}; final tags = []; final MediaSource? source; final String? hashtag; final String? siteUrl; final OriginCountry? countryOfOrigin; final bool isAdult; final externalLinks = []; factory MediaInfo(Map map, ImageQuality imageQuality) { String? duration; if (map['duration'] != null) { final time = map['duration']; final hours = time ~/ 60; final minutes = time % 60; duration = '${hours != 0 ? '$hours hours ' : ''}${minutes != 0 ? '$minutes mins' : ''}'; } String? season; if (map['season'] != null) { season = map['season']; season = season![0] + season.substring(1).toLowerCase(); if (map['seasonYear'] != null) season += ' ${map["seasonYear"]}'; } String description = map['description'] ?? ''; description = description.replaceAll(_forbiddenDescriptionTags, ''); final model = MediaInfo._( id: map['id'], isAnime: map['type'] == 'ANIME', preferredTitle: map['title']['userPreferred'], romajiTitle: map['title']['romaji'], englishTitle: map['title']['english'], nativeTitle: map['title']['native'], synonyms: List.from(map['synonyms'] ?? [], growable: false), description: description, cover: map['coverImage'][imageQuality.value], extraLargeCover: map['coverImage']['extraLarge'], banner: map['bannerImage'], format: MediaFormat.from(map['format']), status: ReleaseStatus.from(map['status']), nextEpisode: map['nextAiringEpisode']?['episode'], airingAt: DateTimeExtension.tryFromSecondsSinceEpoch(map['nextAiringEpisode']?['airingAt']), episodes: map['episodes'], duration: duration, chapters: map['chapters'], volumes: map['volumes'], startDate: StringExtension.fromFuzzyDate(map['startDate']), endDate: StringExtension.fromFuzzyDate(map['endDate']), season: season, averageScore: map['averageScore'] ?? 0, meanScore: map['meanScore'] ?? 0, popularity: map['popularity'] ?? 0, favourites: map['favourites'] ?? 0, isFavorite: map['isFavourite'] ?? false, genres: List.from(map['genres'] ?? [], growable: false), source: MediaSource.from(map['source']), hashtag: map['hashtag'], siteUrl: map['siteUrl'], countryOfOrigin: OriginCountry.fromCode(map['countryOfOrigin']), isAdult: map['isAdult'] ?? false, ); if (map['studios'] != null) { final List companies = map['studios']['edges']; for (final company in companies) { if (company['isMain']) { model.studios[company['node']['name']] = company['node']['id']; } else { model.producers[company['node']['name']] = company['node']['id']; } } } if (map['tags'] != null) { for (final tag in map['tags']) { model.tags.add(Tag(tag)); } } if (map['externalLinks'] != null) { for (final link in map['externalLinks']) { model.externalLinks.add(( url: link['url'], site: link['site'], type: ExternalLinkType.fromString(link['type']), color: link['color'] != null ? ColorExtension.fromHexString(link['color']) : null, countryCode: StringExtension.languageToCode(link['language']), )); } model.externalLinks.sort( (a, b) => a.type == b.type ? a.site.compareTo(b.site) : a.type.index.compareTo(b.type.index), ); } return model; } /// Unexpected html tags in the description only make rendering harder. static final _forbiddenDescriptionTags = RegExp(''); } typedef ExternalLink = ({ String url, String site, ExternalLinkType type, Color? color, String? countryCode, }); enum ExternalLinkType { info, social, streaming; static ExternalLinkType fromString(String? str) => switch (str) { 'SOCIAL' => .social, 'STREAMING' => .streaming, _ => .info, }; } class MediaRank { const MediaRank({ required this.text, required this.typeIsScore, required this.season, required this.year, }); final String text; final bool typeIsScore; final MediaSeason? season; final int? year; } class MediaStats { MediaStats._(); final ranks = []; final scoreNames = []; final scoreValues = []; final statusNames = []; final statusValues = []; factory MediaStats(Map map) { final model = MediaStats._(); // The key is the text and the value signals // if the rank is about rating or popularity. if (map['rankings'] != null) { for (final r in map['rankings']) { final season = MediaSeason.from(r['season']); final String when = (r['allTime'] ?? false) ? 'Ever' : season != null ? '${season.label} ${r['year'] ?? ''}' : (r['year'] ?? '').toString(); if (when.isEmpty) continue; model.ranks.add( MediaRank( text: r['type'] == 'RATED' ? '#${r["rank"]} Highest Rated $when' : '#${r["rank"]} Most Popular $when', typeIsScore: r['type'] == 'RATED', season: season, year: r['year'], ), ); } } if (map['stats'] != null) { if (map['stats']['scoreDistribution'] != null) { for (final s in map['stats']['scoreDistribution']) { model.scoreNames.add(s['score']); model.scoreValues.add(s['amount']); } } if (map['stats']['statusDistribution'] != null) { for (final s in map['stats']['statusDistribution']) { int index = -1; for (int i = 0; i < model.statusValues.length; i++) { if (model.statusValues[i] < s['amount']) { model.statusValues.insert(i, s['amount']); index = i; break; } } if (index < 0) { index = model.statusValues.length; model.statusValues.add(s['amount']); } model.statusNames.insert( index, ListStatus.from(s['status'])!.label(map['type'] == 'ANIME'), ); } } } return model; } } enum MediaTab { info, relations, characters, staff, reviews, threads, following, activities, recommendations, statistics, } enum MediaType { anime('Anime', 'ANIME'), manga('Manga', 'MANGA'); const MediaType(this.label, this.value); final String label; final String value; } enum ReleaseStatus { finished('Finished', 'FINISHED'), releasing('Releasing', 'RELEASING'), notYetReleased('Not Yet Released', 'NOT_YET_RELEASED'), hiatus('Hiatus', 'HIATUS'), cancelled('Cancelled', 'CANCELLED'); const ReleaseStatus(this.label, this.value); final String label; final String value; static ReleaseStatus? from(String? value) => ReleaseStatus.values.firstWhereOrNull((v) => v.value == value); } enum MediaFormat { tv('TV', 'TV'), tvShort('TV Short', 'TV_SHORT'), movie('Movie', 'MOVIE'), special('Special', 'SPECIAL'), ova('OVA', 'OVA'), ona('ONA', 'ONA'), music('Music', 'MUSIC'), manga('Manga', 'MANGA'), novel('Novel', 'NOVEL'), oneShot('One Shot', 'ONE_SHOT'); const MediaFormat(this.label, this.value); final String label; final String value; static const animeFormats = [tv, tvShort, movie, special, ova, ona, music]; static const mangaFormats = [manga, novel, oneShot]; static MediaFormat? from(String? value) => MediaFormat.values.firstWhereOrNull((v) => v.value == value); } enum MediaSeason { winter('Winter', 'WINTER'), spring('Spring', 'SPRING'), summer('Summer', 'SUMMER'), fall('Fall', 'FALL'); const MediaSeason(this.label, this.value); final String label; final String value; static MediaSeason? from(String? value) => MediaSeason.values.firstWhereOrNull((v) => v.value == value); } enum MediaSource { original('Original', 'ORIGINAL'), anime('Anime', 'ANIME'), manga('Manga', 'MANGA'), novel('Novel', 'NOVEL'), webNovel('Web Novel', 'WEB_NOVEL'), lightNovel('Light Novel', 'LIGHT_NOVEL'), visualNovel('Visual Novel', 'VISUAL_NOVEL'), videoGame('Video Game', 'VIDEO_GAME'), doujinshi('Doujinshi', 'DOUJINSHI'), game('Game', 'GAME'), comic('Comic', 'COMIC'), liveAction('Live Action', 'LIVE_ACTION'), multimediaProject('Multimedia Project', 'MULTIMEDIA_PROJECT'), pictureBook('Picture Book', 'PICTURE_BOOK'), other('Other', 'OTHER'); const MediaSource(this.label, this.value); final String label; final String value; static MediaSource? from(String? value) => MediaSource.values.firstWhereOrNull((v) => v.value == value); } enum OriginCountry { japan('Japan', 'JP'), china('China', 'CN'), southKorea('South Korea', 'KR'), taiwan('Taiwan', 'TW'); const OriginCountry(this.label, this.code); final String label; final String code; static OriginCountry? fromCode(String? code) => OriginCountry.values.firstWhereOrNull((v) => v.code == code); } enum ScoreFormat { point100('100 Points', 'POINT_100'), point10Decimal('10 Decimal Points', 'POINT_10_DECIMAL'), point10('10 Points', 'POINT_10'), point5('5 Stars', 'POINT_5'), point3('3 Smileys', 'POINT_3'); const ScoreFormat(this.label, this.value); final String label; final String value; static ScoreFormat from(String? value) => ScoreFormat.values.firstWhere((v) => v.value == value, orElse: () => point10); } enum MediaSort { trendingDesc('Trending', 'TRENDING_DESC'), popularityDesc('Popularity', 'POPULARITY_DESC'), scoreDesc('Score', 'SCORE_DESC'), score('Worst Score', 'SCORE'), favoritesDesc('Favourites', 'FAVOURITES_DESC'), startDateDesc('Released Latest', 'START_DATE_DESC'), startDate('Released Earliest', 'START_DATE'), idDesc('Last Added', 'ID_DESC'), id('First Added', 'ID'), titleRomaji('Title Romaji', 'TITLE_ROMAJI'), titleEnglish('Title English', 'TITLE_ENGLISH'), titleNative('Title Native', 'TITLE_NATIVE'); const MediaSort(this.label, this.value); final String label; final String value; } enum EntrySort { title('Title'), titleDesc('Title'), score('Score'), scoreDesc('Score'), updated('Updated'), updatedDesc('Updated'), added('Added'), addedDesc('Added'), airing('Airing'), airingDesc('Airing'), startedOn('Started'), startedOnDesc('Started'), completedOn('Completed'), completedOnDesc('Completed'), releasedOn('Released'), releasedOnDesc('Released'), progress('Progress'), progressDesc('Progress'), avgScore('Rating'), avgScoreDesc('Rating'), repeated('Repeats'), repeatedDesc('Repeats'); const EntrySort(this.label); final String label; /// The API supports only few default sortings. static const rowOrders = [scoreDesc, title, updatedDesc, addedDesc]; /// Serialize to API row order. String toRowOrder() => switch (this) { scoreDesc => 'score', updatedDesc => 'updatedAt', addedDesc => 'id', title => 'title', _ => 'title', }; /// Deserialize from API row order. static EntrySort fromRowOrder(String key) => switch (key) { 'score' => scoreDesc, 'updatedAt' => updatedDesc, 'id' => addedDesc, 'title' => title, _ => title, }; } ================================================ FILE: lib/feature/media/media_overview_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/action_chip_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/discover/discover_filter_model.dart'; import 'package:otraku/feature/media/media_provider.dart'; import 'package:otraku/feature/tag/tag_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/table_list.dart'; import 'package:otraku/feature/discover/discover_filter_provider.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; class MediaOverviewSubview extends StatelessWidget { const MediaOverviewSubview.asFragment({ required this.info, required this.ref, required this.highContrast, required ScrollController this.scrollCtrl, }) : header = null; const MediaOverviewSubview.withHeader({ required this.info, required this.ref, required this.highContrast, required Widget this.header, }) : scrollCtrl = null; final WidgetRef ref; final MediaInfo info; final Widget? header; final ScrollController? scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { String? release; if (info.startDate != null) { if (info.endDate != null) { if (info.startDate != info.endDate) { release = '${info.startDate} - ${info.endDate}'; } else { release = info.startDate!; } } else { release = '${info.startDate} - ?'; } } final details = [ if (release != null) ('Release', release), if (info.status != null) ('Status', info.status!.label), if (info.episodes != null) ('Episodes', info.episodes!.toString()), if (info.duration != null) ('Duration', info.duration!), if (info.chapters != null) ('Chapters', info.chapters!.toString()), if (info.volumes != null) ('Volumes', info.volumes!.toString()), if (info.season != null) ('Season', info.season!), if (info.source != null) ('Source', info.source!.label), if (info.countryOfOrigin != null) ('Origin', info.countryOfOrigin!.label), ]; final titles = [ if (info.hashtag != null) ('Hashtag', info.hashtag!), if (info.romajiTitle != null) ('Romaji', info.romajiTitle!), if (info.englishTitle != null) ('English', info.englishTitle!), if (info.nativeTitle != null) ('Native', info.nativeTitle!), ...info.synonyms.map((s) => ('Synonym', s)), ]; const spacing = SliverToBoxAdapter(child: SizedBox(height: Theming.offset)); final mediaQuery = MediaQuery.of(context); final refreshControl = SliverRefreshControl( onRefresh: () => ref.invalidate(mediaProvider(info.id)), ); return CustomScrollView( controller: scrollCtrl, physics: Theming.bouncyPhysics, slivers: [ if (header != null) ...[ header!, MediaQuery( data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)), child: refreshControl, ), ] else refreshControl, SliverPadding( padding: const .symmetric(horizontal: Theming.offset), sliver: SliverMainAxisGroup( slivers: [ if (info.description.isNotEmpty) _Description(info.description, highContrast), SliverToBoxAdapter( child: CardExtension.highContrast(highContrast)( child: Padding( padding: Theming.paddingAll, child: Row( mainAxisAlignment: .spaceEvenly, children: [ _IconTile( text: info.favourites.toString(), tooltip: 'Favorites', icon: Icons.favorite_outline_rounded, ), _IconTile( text: info.popularity.toString(), tooltip: 'Popularity', icon: Icons.person_outline_rounded, ), _IconTile( text: info.averageScore.toString(), tooltip: 'Weighted Average Score', icon: Icons.percent_rounded, ), _IconTile( text: info.meanScore.toString(), tooltip: 'Mean Score', icon: Ionicons.star_half_outline, ), ], ), ), ), ), spacing, SliverTableList(details, highContrast: highContrast), if (info.genres.isNotEmpty) _Wrap( title: 'Genres', children: info.genres .map((genre) => _buildGenreActionChip(context, genre, highContrast)) .toList(), ), if (info.tags.isNotEmpty) _TagsWrap( ref: ref, tags: info.tags, isAnime: info.isAnime, highContrast: highContrast, ), if (info.studios.isNotEmpty) _Wrap( title: 'Studios', children: info.studios.entries .map( (studio) => _buildStudioActionChip(context, studio.key, studio.value, highContrast), ) .toList(), ), if (info.producers.isNotEmpty) _Wrap( title: 'Producers', children: info.producers.entries .map( (studio) => _buildStudioActionChip(context, studio.key, studio.value, highContrast), ) .toList(), ), if (info.externalLinks.isNotEmpty) _Wrap( title: 'External links', children: info.externalLinks .map((link) => _buildExternalLinkChip(context, link, highContrast)) .toList(), ), spacing, spacing, SliverTableList(titles, highContrast: highContrast), ], ), ), SliverToBoxAdapter( child: SizedBox( height: MediaQuery.paddingOf(context).bottom + Theming.normalTapTarget + 26, ), ), ], ); } Widget _buildGenreActionChip(BuildContext context, String genre, bool highContrast) { return ActionChipExtension.highContrast(highContrast)( label: Text(genre), tooltip: 'Filter By Genre', onPressed: () { final notifier = ref.read(discoverFilterProvider.notifier); final filter = notifier.state.copyWith( type: info.isAnime ? .anime : .manga, search: '', mediaFilter: DiscoverMediaFilter(notifier.state.mediaFilter.sort), )..mediaFilter.genreIn.add(genre); notifier.state = filter; context.go(Routes.home(.discover)); }, ); } Widget _buildStudioActionChip(BuildContext context, String name, int id, bool highContrast) { return ActionChipExtension.highContrast(highContrast)( label: Text(name), tooltip: 'Open Studio', onPressed: () => context.push(Routes.studio(id, name)), ); } Widget _buildExternalLinkChip(BuildContext context, ExternalLink link, bool highContrast) { return _Chip( label: link.countryCode == null ? Text(link.site) : Text('${link.site} ${link.countryCode}'), onTap: () => SnackBarExtension.launch(context, link.url), onLongTap: () => SnackBarExtension.copy(context, link.url), onTapHint: 'open external link', onLongTapHint: 'copy external link', highContrast: highContrast, leading: Container( width: 15, height: 15, decoration: BoxDecoration(borderRadius: Theming.borderRadiusSmall, color: link.color), ), ); } } class _Description extends StatefulWidget { const _Description(this.text, this.highContrast); final String text; final bool highContrast; @override State<_Description> createState() => _DescriptionState(); } class _DescriptionState extends State<_Description> { bool _expanded = false; @override Widget build(BuildContext context) { final content = _expanded ? HtmlContent(widget.text) : ShaderMask( shaderCallback: (bounds) => const LinearGradient( begin: Alignment(0.0, 0.3), end: Alignment(0.0, 1.0), colors: [Colors.white, Colors.transparent], ).createShader(bounds), child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 72), child: HtmlContent(widget.text), ), ); return SliverToBoxAdapter( child: Padding( padding: const .only(bottom: Theming.offset), child: CardExtension.highContrast(widget.highContrast)( child: InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => setState(() => _expanded = !_expanded), onLongPress: () { final text = widget.text.replaceAll(RegExp(r'
'), ''); SnackBarExtension.copy(context, text); }, child: Padding(padding: const .all(Theming.offset), child: content), ), ), ), ); } } class _IconTile extends StatelessWidget { const _IconTile({required this.text, required this.tooltip, required this.icon}); final String text; final String tooltip; final IconData icon; @override Widget build(BuildContext context) { return Tooltip( message: tooltip, triggerMode: .tap, child: Column( mainAxisSize: .min, spacing: 5, children: [ Icon(icon, size: Theming.iconSmall, color: ColorScheme.of(context).onSurfaceVariant), Text(text), ], ), ); } } class _Wrap extends StatelessWidget { const _Wrap({required this.title, required this.children, this.trailingAction}); final String title; final Widget? trailingAction; final List children; @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Column( mainAxisSize: .min, crossAxisAlignment: .stretch, children: [ Row( children: [ Expanded(child: Text(title)), if (trailingAction != null) trailingAction! else const SizedBox(height: Theming.minTapTarget), ], ), Wrap(spacing: 5, children: children), ], ), ); } } class _TagsWrap extends StatefulWidget { const _TagsWrap({ required this.ref, required this.tags, required this.isAnime, required this.highContrast, }); final WidgetRef ref; final List tags; final bool isAnime; final bool highContrast; @override State<_TagsWrap> createState() => __TagsWrapState(); } class __TagsWrapState extends State<_TagsWrap> { bool? _showSpoilers; @override void initState() { super.initState(); for (final t in widget.tags) { if (t.isSpoiler) { _showSpoilers = false; break; } } } @override Widget build(BuildContext context) { final tags = _showSpoilers == null || _showSpoilers! ? widget.tags : widget.tags.where((t) => !t.isSpoiler).toList(); final spoilerColor = ColorScheme.of(context).error; return _Wrap( title: 'Tags', trailingAction: _showSpoilers != null ? IconButton( icon: _showSpoilers! ? const Icon(Ionicons.eye_off_outline) : const Icon(Ionicons.eye_outline), tooltip: _showSpoilers! ? 'Hide Spoilers' : 'Show Spoilers', onPressed: () => setState(() => _showSpoilers = !_showSpoilers!), ) : null, children: tags.map((tag) => _buildTagChip(tag, spoilerColor)).toList(), ); } Widget _buildTagChip(Tag tag, Color spoilerColor) { return _Chip( label: Text( '${tag.name} ${tag.rank}%', style: tag.isSpoiler ? TextStyle(color: spoilerColor) : null, ), onTapHint: 'filter by this tag', onLongTapHint: 'show tag description', highContrast: widget.highContrast, onTap: () { final notifier = widget.ref.read(discoverFilterProvider.notifier); final filter = notifier.state.copyWith( type: widget.isAnime ? .anime : .manga, search: '', mediaFilter: DiscoverMediaFilter(notifier.state.mediaFilter.sort), )..mediaFilter.tagIn.add(tag.name); notifier.state = filter; context.go(Routes.home(.discover)); }, onLongTap: () => showDialog( context: context, builder: (context) => TextDialog(title: tag.name, text: tag.desciption), ), ); } } class _Chip extends StatelessWidget { const _Chip({ required this.label, required this.highContrast, this.leading, this.onTap, this.onLongTap, this.onTapHint, this.onLongTapHint, }); final Widget label; final Widget? leading; final void Function()? onTap; final void Function()? onLongTap; final String? onTapHint; final String? onLongTapHint; final bool highContrast; @override Widget build(BuildContext context) { return MergeSemantics( child: Semantics( onTapHint: onTapHint, onLongPressHint: onLongTapHint, child: GestureDetector( onLongPress: onLongTap, child: ActionChipExtension.highContrast(highContrast)( label: label, avatar: leading, onPressed: onTap, ), ), ), ); } } ================================================ FILE: lib/feature/media/media_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/extension/iterable_extension.dart'; import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/feature/edit/edit_model.dart'; import 'package:otraku/feature/forum/forum_model.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/settings/settings_provider.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; import 'package:otraku/util/paged.dart'; final mediaProvider = AsyncNotifierProvider.autoDispose.family( MediaNotifier.new, ); final mediaConnectionsProvider = AsyncNotifierProvider.autoDispose .family(MediaRelationsNotifier.new); final mediaThreadsProvider = AsyncNotifierProvider.family, int>( MediaThreadsNotifier.new, ); final mediaFollowingProvider = AsyncNotifierProvider.family, int>( MediaFollowingNotifier.new, ); class MediaNotifier extends AsyncNotifier { MediaNotifier(this.arg); final int arg; @override FutureOr build() async { var data = await ref.read(repositoryProvider).request(GqlQuery.media, { 'id': arg, 'withInfo': true, }); data = data['Media']; final imageQuality = ref.read(persistenceProvider).options.imageQuality; final relatedMedia = []; for (final relation in data['relations']['edges']) { if (relation['node'] != null) { relatedMedia.add(RelatedMedia(relation, imageQuality)); } } final settings = await ref.watch(settingsProvider.selectAsync((settings) => settings)); return Media( EntryEdit(data, settings, false), MediaInfo(data, imageQuality), MediaStats(data), relatedMedia, ); } Future toggleFavorite() { final value = state.value; if (value == null) return Future.value('User not yet loaded'); final typeKey = value.info.isAnime ? 'anime' : 'manga'; return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, { typeKey: arg, }).getErrorOrNull(); } } class MediaRelationsNotifier extends AsyncNotifier { MediaRelationsNotifier(this.arg); final int arg; @override FutureOr build() => _fetch(const MediaConnections(), null); Future fetch(MediaTab tab) async { final oldState = state.value ?? const MediaConnections(); state = switch (tab) { .info || .relations || .threads || .following || .activities || .statistics => state, .characters => oldState.characters.hasNext ? await AsyncValue.guard(() => _fetch(oldState, tab)) : state, .staff => oldState.staff.hasNext ? await AsyncValue.guard(() => _fetch(oldState, tab)) : state, .reviews => oldState.reviews.hasNext ? await AsyncValue.guard(() => _fetch(oldState, tab)) : state, .recommendations => oldState.recommendations.hasNext ? await AsyncValue.guard(() => _fetch(oldState, tab)) : state, }; } Future _fetch(MediaConnections oldState, MediaTab? tab) async { final variables = {'id': arg}; if (tab == null) { variables['withRecommendations'] = true; variables['withCharacters'] = true; variables['withStaff'] = true; variables['withReviews'] = true; } else if (tab == .recommendations) { variables['withRecommendations'] = true; variables['page'] = oldState.recommendations.next; } else if (tab == .characters) { variables['withCharacters'] = true; variables['page'] = oldState.characters.next; } else if (tab == .staff) { variables['withStaff'] = true; variables['page'] = oldState.staff.next; } else if (tab == .reviews) { variables['withReviews'] = true; variables['page'] = oldState.reviews.next; } var data = await ref.read(repositoryProvider).request(GqlQuery.media, variables); data = data['Media']; final imageQuality = ref.read(persistenceProvider).options.imageQuality; var characters = oldState.characters; var staff = oldState.staff; var reviews = oldState.reviews; var recommendations = oldState.recommendations; var languageToVoiceActors = [...oldState.languageToVoiceActors]; var selectedLanguage = oldState.selectedLanguage; if (tab == null || tab == .characters) { final map = data['characters']; final items = []; for (final c in map['edges']) { final role = StringExtension.tryNoScreamingSnakeCase(c['role']); items.add(MediaRelatedItem(c['node'], role)); if (c['voiceActors'] == null) continue; for (final va in c['voiceActors']) { final l = StringExtension.tryNoScreamingSnakeCase(va['languageV2']); if (l == null) continue; var languageMapping = languageToVoiceActors.firstWhereOrNull((lm) => lm.language == l); if (languageMapping == null) { languageMapping = (language: l, voiceActors: {}); languageToVoiceActors.add(languageMapping); } final characterVoiceActors = languageMapping.voiceActors.putIfAbsent( items.last.id, () => [], ); characterVoiceActors.add(MediaRelatedItem(va, l)); } } languageToVoiceActors.sort((a, b) { if (a.language == 'Japanese') return -1; if (b.language == 'Japanese') return 1; return a.language.compareTo(b.language); }); characters = characters.withNext(items, map['pageInfo']['hasNextPage'] ?? false); } if (tab == null || tab == .staff) { final map = data['staff']; final items = []; for (final s in map['edges']) { items.add(MediaRelatedItem(s['node'], s['role'])); } staff = staff.withNext(items, map['pageInfo']['hasNextPage'] ?? false); } if (tab == null || tab == .reviews) { final map = data['reviews']; final items = []; for (final r in map['nodes']) { final item = RelatedReview.maybe(r); if (item != null) items.add(item); } reviews = reviews.withNext(items, map['pageInfo']['hasNextPage'] ?? false); } if (tab == null || tab == .recommendations) { final map = data['recommendations']; final items = []; for (final r in map['nodes']) { if (r['mediaRecommendation'] != null) { items.add(Recommendation(r, imageQuality)); } } recommendations = recommendations.withNext(items, map['pageInfo']['hasNextPage'] ?? false); } return oldState.copyWith( recommendations: recommendations, characters: characters, staff: staff, reviews: reviews, languageToVoiceActors: languageToVoiceActors, selectedLanguage: selectedLanguage, ); } void changeLanguage(int selectedLanguage) => state.whenData((data) { if (selectedLanguage >= data.languageToVoiceActors.length) return; state = AsyncValue.data( MediaConnections( recommendations: data.recommendations, characters: data.characters, staff: data.staff, reviews: data.reviews, languageToVoiceActors: data.languageToVoiceActors, selectedLanguage: selectedLanguage, ), ); }); Future rateRecommendation(int recId, bool? rating) { return ref.read(repositoryProvider).request(GqlMutation.rateRecommendation, { 'id': arg, 'recommendedId': recId, 'rating': rating == null ? 'NO_RATING' : rating ? 'RATE_UP' : 'RATE_DOWN', }).getErrorOrNull(); } } class MediaThreadsNotifier extends AsyncNotifier> { MediaThreadsNotifier(this.arg); final int arg; @override FutureOr> build() => _fetch(const Paged()); Future fetch() async { final oldState = state.value ?? const Paged(); if (!oldState.hasNext) return; state = await AsyncValue.guard(() => _fetch(oldState)); } Future> _fetch(Paged oldState) async { final data = await ref.read(repositoryProvider).request(GqlQuery.threadPage, { 'mediaId': arg, 'page': oldState.next, 'sort': 'ID_DESC', }); final items = []; for (final t in data['Page']['threads']) { items.add(ThreadItem(t)); } return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false); } } class MediaFollowingNotifier extends AsyncNotifier> { MediaFollowingNotifier(this.arg); final int arg; @override FutureOr> build() => _fetch(const Paged()); Future fetch() async { final oldState = state.value ?? const Paged(); if (!oldState.hasNext) return; state = await AsyncValue.guard(() => _fetch(oldState)); } Future> _fetch(Paged oldState) async { final data = await ref.read(repositoryProvider).request(GqlQuery.mediaFollowing, { 'mediaId': arg, 'page': oldState.next, }); final items = []; for (final f in data['Page']['mediaList']) { items.add(MediaFollowing(f)); } return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false); } } ================================================ FILE: lib/feature/media/media_recommendations_view.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/media/media_provider.dart'; import 'package:otraku/widget/text_rail.dart'; class MediaRecommendationsSubview extends StatelessWidget { const MediaRecommendationsSubview({ required this.id, required this.scrollCtrl, required this.rateRecommendation, required this.highContrast, }); final int id; final ScrollController scrollCtrl; final Future Function(int, bool?) rateRecommendation; final bool highContrast; @override Widget build(BuildContext context) { return PagedView( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(mediaConnectionsProvider(id)), provider: mediaConnectionsProvider( id, ).select((s) => s.unwrapPrevious().whenData((data) => data.recommendations)), onData: (data) => _MediaRecommendationsGrid(id, data.items, rateRecommendation, highContrast), ); } } class _MediaRecommendationsGrid extends StatelessWidget { const _MediaRecommendationsGrid( this.mediaId, this.items, this.rateRecommendation, this.highContrast, ); final int mediaId; final List items; final Future Function(int, bool?) rateRecommendation; final bool highContrast; @override Widget build(BuildContext context) { if (items.isEmpty) { return const SliverFillRemaining(child: Center(child: Text('No results'))); } final textTheme = TextTheme.of(context); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!); final tileHeight = bodyMediumLineHeight * 2 + max(labelMediumLineHeight * 2, Theming.iconSmall) + 10; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 270, height: tileHeight), delegate: SliverChildBuilderDelegate(childCount: items.length, (context, i) { final textRailItems = { if (items[i].entryStatus != null) items[i].entryStatus!.label(items[i].isAnime): true, if (items[i].format != null) items[i].format!.label: false, if (items[i].releaseYear != null) items[i].releaseYear!.toString(): false, }; return CardExtension.highContrast(highContrast)( child: MediaRouteTile( id: items[i].id, imageUrl: items[i].imageUrl, child: Row( children: [ Hero( tag: items[i].id, child: ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: Container( color: ColorScheme.of(context).surfaceContainerHighest, child: CachedImage( items[i].imageUrl, width: tileHeight / Theming.coverHtoWRatio, ), ), ), ), Expanded( child: Padding( padding: const .symmetric(horizontal: Theming.offset, vertical: 5), child: Column( crossAxisAlignment: .start, mainAxisAlignment: .spaceAround, children: [ Flexible(child: Text(items[i].title, overflow: .ellipsis, maxLines: 2)), TextRail( textRailItems, style: TextTheme.of(context).labelMedium, maxLines: 2, ), ], ), ), ), Padding( padding: const .symmetric(vertical: 5), child: const VerticalDivider(thickness: 1, width: 1), ), _RecommendationRating(mediaId, items[i], rateRecommendation), ], ), ), ); }), ); } } class _RecommendationRating extends StatefulWidget { const _RecommendationRating(this.mediaId, this.item, this.rateRecommendation); final int mediaId; final Recommendation item; final Future Function(int, bool?) rateRecommendation; @override State<_RecommendationRating> createState() => _RecommendationRatingState(); } class _RecommendationRatingState extends State<_RecommendationRating> { @override Widget build(BuildContext context) { final item = widget.item; return Padding( padding: const .symmetric(horizontal: Theming.offset, vertical: 5), child: Column( mainAxisAlignment: MainAxisAlignment.center, spacing: Theming.offset, children: [ Text(item.rating.toString()), Row( spacing: Theming.offset, mainAxisAlignment: .spaceEvenly, children: [ Tooltip( message: 'Agree', child: InkResponse( onTap: () async { final oldRating = item.rating; final oldUserRating = item.userRating; setState(() { switch (item.userRating) { case true: item.rating--; item.userRating = null; break; case false: item.rating += 2; item.userRating = true; break; case null: item.rating++; item.userRating = true; break; } }); final err = await widget.rateRecommendation(item.id, item.userRating); if (err == null) return; setState(() { item.rating = oldRating; item.userRating = oldUserRating; }); if (context.mounted) { SnackBarExtension.show(context, err.toString()); } }, child: item.userRating == true ? Icon( Icons.thumb_up, size: Theming.iconSmall, color: ColorScheme.of(context).primary, ) : Icon( Icons.thumb_up_outlined, size: Theming.iconSmall, color: ColorScheme.of(context).onSurface, ), ), ), Tooltip( message: 'Disagree', child: InkResponse( onTap: () async { final oldRating = item.rating; final oldUserRating = item.userRating; setState(() { switch (item.userRating) { case true: item.rating -= 2; item.userRating = false; break; case false: item.rating++; item.userRating = null; break; case null: item.rating--; item.userRating = false; break; } }); final err = await widget.rateRecommendation(item.id, item.userRating); if (err == null) return; setState(() { item.rating = oldRating; item.userRating = oldUserRating; }); if (context.mounted) { SnackBarExtension.show(context, err.toString()); } }, child: item.userRating == false ? Icon( Icons.thumb_down, size: Theming.iconSmall, color: ColorScheme.of(context).error, ) : Icon( Icons.thumb_down_outlined, size: Theming.iconSmall, color: ColorScheme.of(context).onSurface, ), ), ), ], ), ], ), ); } } ================================================ FILE: lib/feature/media/media_related_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/text_rail.dart'; import 'package:otraku/feature/media/media_models.dart'; class MediaRelatedSubview extends StatelessWidget { const MediaRelatedSubview({ required this.relations, required this.scrollCtrl, required this.invalidate, required this.highContrast, }); final List relations; final ScrollController scrollCtrl; final void Function() invalidate; final bool highContrast; @override Widget build(BuildContext context) { return ConstrainedView( child: CustomScrollView( controller: scrollCtrl, physics: Theming.bouncyPhysics, slivers: [ SliverRefreshControl(onRefresh: invalidate), _MediaRelatedGrid(relations, highContrast), const SliverFooter(), ], ), ); } } class _MediaRelatedGrid extends StatelessWidget { const _MediaRelatedGrid(this.items, this.highContrast); final List items; final bool highContrast; @override Widget build(BuildContext context) { if (items.isEmpty) { return const SliverFillRemaining(child: Center(child: Text('No results'))); } final textTheme = TextTheme.of(context); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!); final tileHeight = bodyMediumLineHeight * 2 + labelMediumLineHeight * 2 + 25; final coverWidth = tileHeight / Theming.coverHtoWRatio; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 270, height: tileHeight), delegate: SliverChildBuilderDelegate(childCount: items.length, (context, i) { final textRailItems = { if (items[i].relationType != null) items[i].relationType!: true, if (items[i].entryStatus != null) items[i].entryStatus!.label(items[i].isAnime): true, if (items[i].format != null) items[i].format!.label: false, if (items[i].releaseStatus != null) items[i].releaseStatus!: false, }; return CardExtension.highContrast(highContrast)( child: MediaRouteTile( id: items[i].id, imageUrl: items[i].imageUrl, child: Row( mainAxisAlignment: .start, children: [ Hero( tag: items[i].id, child: ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: Container( color: ColorScheme.of(context).surfaceContainerHighest, child: CachedImage(items[i].imageUrl, width: coverWidth), ), ), ), Expanded( child: Padding( padding: Theming.paddingAll, child: Column( mainAxisAlignment: .spaceEvenly, crossAxisAlignment: .start, spacing: 5, children: [ Flexible(child: Text(items[i].title, overflow: .ellipsis, maxLines: 2)), TextRail( textRailItems, style: TextTheme.of(context).labelMedium, maxLines: 2, ), ], ), ), ), ], ), ), ); }), ); } } ================================================ FILE: lib/feature/media/media_reviews_view.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/media/media_provider.dart'; class MediaReviewsSubview extends StatelessWidget { const MediaReviewsSubview({ required this.id, required this.scrollCtrl, required this.bannerUrl, required this.highContrast, }); final int id; final ScrollController scrollCtrl; final String? bannerUrl; final bool highContrast; @override Widget build(BuildContext context) { return PagedView( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(mediaConnectionsProvider(id)), provider: mediaConnectionsProvider( id, ).select((s) => s.unwrapPrevious().whenData((data) => data.reviews)), onData: (data) => _MediaReviewGrid(data.items, bannerUrl, highContrast), ); } } class _MediaReviewGrid extends StatelessWidget { const _MediaReviewGrid(this.items, this.bannerUrl, this.highContrast); final List items; final String? bannerUrl; final bool highContrast; @override Widget build(BuildContext context) { if (items.isEmpty) { return const SliverFillRemaining(child: Center(child: Text('No results'))); } const avatarSize = 50.0; const verticalDivider = SizedBox(height: 20, child: VerticalDivider(thickness: 1, width: 20)); final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final tileHeight = max(avatarSize, bodyMediumLineHeight) + bodyMediumLineHeight * 3 + 25; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 300, height: tileHeight), delegate: SliverChildBuilderDelegate( childCount: items.length, (context, i) => Column( crossAxisAlignment: .start, spacing: 5, children: [ Row( children: [ Expanded( child: GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.user(items[i].userId, items[i].avatar)), child: Row( mainAxisSize: .min, spacing: Theming.offset, children: [ ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage( items[i].avatar, height: avatarSize, width: avatarSize, ), ), Flexible(child: Text(items[i].username, overflow: .ellipsis, maxLines: 1)), ], ), ), ), verticalDivider, Tooltip( message: 'Reviewer Score', triggerMode: .tap, child: Row( mainAxisSize: .min, spacing: 5, children: [ const Icon(Icons.star_half_rounded, size: Theming.iconSmall), Text(items[i].score.toString()), ], ), ), verticalDivider, Tooltip( message: 'Review Rating', triggerMode: .tap, child: Row( mainAxisSize: .min, spacing: 5, children: [ const Icon(Icons.thumb_up_outlined, size: Theming.iconSmall), Text(items[i].rating), ], ), ), ], ), Expanded( child: GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.review(items[i].reviewId, bannerUrl)), child: CardExtension.highContrast(highContrast)( child: SizedBox( width: double.infinity, child: Padding( padding: Theming.paddingAll, child: Text(items[i].summary, overflow: .ellipsis, maxLines: 3), ), ), ), ), ), ], ), ), ); } } ================================================ FILE: lib/feature/media/media_route_tile.dart ================================================ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/edit/edit_view.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/sheets.dart'; class MediaRouteTile extends StatelessWidget { const MediaRouteTile({super.key, required this.id, required this.imageUrl, required this.child}); final int id; final String? imageUrl; final Widget child; @override Widget build(BuildContext context) { return InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => context.push(Routes.media(id, imageUrl)), onLongPress: () => showSheet(context, EditView((id: id, setComplete: false))), child: child, ); } } ================================================ FILE: lib/feature/media/media_staff_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/widget/grid/mono_relation_grid.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/feature/media/media_provider.dart'; class MediaStaffSubview extends StatelessWidget { const MediaStaffSubview({required this.id, required this.scrollCtrl, required this.highContrast}); final int id; final ScrollController scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { return PagedView( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(mediaConnectionsProvider(id)), provider: mediaConnectionsProvider( id, ).select((s) => s.unwrapPrevious().whenData((data) => data.staff)), onData: (data) => MonoRelationGrid( items: data.items, onTap: (item) => context.push(Routes.staff(item.tileId, item.tileImageUrl)), highContrast: highContrast, ), ); } } ================================================ FILE: lib/feature/media/media_stats_view.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/discover/discover_filter_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/feature/discover/discover_filter_provider.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/statistics/charts.dart'; class MediaStatsSubview extends StatelessWidget { const MediaStatsSubview({ required this.ref, required this.info, required this.stats, required this.scrollCtrl, required this.highContrast, }); final WidgetRef ref; final MediaInfo info; final MediaStats stats; final ScrollController scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { return ConstrainedView( child: CustomScrollView( controller: scrollCtrl, slivers: [ SliverToBoxAdapter(child: SizedBox(height: MediaQuery.paddingOf(context).top)), if (stats.ranks.isNotEmpty) _MediaRankGrid(ref: ref, info: info, highContrast: highContrast, ranks: stats.ranks), if (stats.scoreNames.isNotEmpty) SliverToBoxAdapter( child: BarChart( title: 'Score Distribution', names: stats.scoreNames.map((n) => n.toString()).toList(), values: stats.scoreValues, ), ), if (stats.statusNames.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const .only(top: Theming.offset), child: SizedBox( height: 200, child: PieChart( title: 'Status Distribution', names: stats.statusNames, values: stats.statusValues, highContrast: highContrast, ), ), ), ), const SliverFooter(), ], ), ); } } class _MediaRankGrid extends StatelessWidget { const _MediaRankGrid({ required this.ref, required this.info, required this.highContrast, required this.ranks, }); final WidgetRef ref; final MediaInfo info; final bool highContrast; final List ranks; @override Widget build(BuildContext context) { final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final tileHeight = max(bodyMediumLineHeight * 2, Theming.iconBig) + 10; return SliverPadding( padding: const .symmetric(vertical: Theming.offset), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight( height: tileHeight, minWidth: 185, ), delegate: SliverChildBuilderDelegate((_, i) { return CardExtension.highContrast(highContrast)( child: InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () { final notifier = ref.read(discoverFilterProvider.notifier); final filter = notifier.state.copyWith( type: info.isAnime ? .anime : .manga, search: '', mediaFilter: DiscoverMediaFilter(notifier.state.mediaFilter.sort), ); filter.mediaFilter.season = ranks[i].season; filter.mediaFilter.startYearFrom = ranks[i].year; filter.mediaFilter.startYearTo = ranks[i].year; filter.mediaFilter.sort = ranks[i].typeIsScore ? .scoreDesc : .popularityDesc; if (info.format != null) { if (info.isAnime) { filter.mediaFilter.animeFormats.add(info.format!); } else { filter.mediaFilter.mangaFormats.add(info.format!); } } notifier.state = filter; context.go(Routes.home(.discover)); }, child: Padding( padding: const .symmetric(horizontal: Theming.offset, vertical: 5), child: Row( spacing: Theming.offset, children: [ Icon( ranks[i].typeIsScore ? Ionicons.star : Icons.favorite_rounded, color: ColorScheme.of(context).onSurfaceVariant, ), Expanded(child: Text(ranks[i].text, overflow: .ellipsis, maxLines: 2)), ], ), ), ), ); }, childCount: ranks.length), ), ); } } ================================================ FILE: lib/feature/media/media_threads_view.dart ================================================ import 'package:flutter/widgets.dart'; import 'package:otraku/feature/forum/thread_item_list.dart'; import 'package:otraku/feature/media/media_provider.dart'; import 'package:otraku/widget/paged_view.dart'; class MediaThreadsSubview extends StatelessWidget { const MediaThreadsSubview({ required this.id, required this.scrollCtrl, required this.highContrast, required this.analogClock, }); final int id; final ScrollController scrollCtrl; final bool highContrast; final bool analogClock; @override Widget build(BuildContext context) { return PagedView( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(mediaThreadsProvider(id)), provider: mediaThreadsProvider(id), onData: (data) => ThreadItemList(data.items, highContrast, analogClock), ); } } ================================================ FILE: lib/feature/media/media_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/scroll_controller_extension.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/feature/activity/activities_provider.dart'; import 'package:otraku/feature/media/media_activities_view.dart'; import 'package:otraku/feature/media/media_floating_actions.dart'; import 'package:otraku/feature/media/media_characters_view.dart'; import 'package:otraku/feature/media/media_following_view.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/media/media_provider.dart'; import 'package:otraku/feature/media/media_recommendations_view.dart'; import 'package:otraku/feature/media/media_related_view.dart'; import 'package:otraku/feature/media/media_reviews_view.dart'; import 'package:otraku/feature/media/media_staff_view.dart'; import 'package:otraku/feature/media/media_stats_view.dart'; import 'package:otraku/feature/media/media_threads_view.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/feature/media/media_overview_view.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/dual_pane_with_tab_bar.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/feature/media/media_header.dart'; class MediaView extends StatefulWidget { const MediaView(this.id, this.coverUrl); final int id; final String? coverUrl; @override State createState() => _MediaViewState(); } class _MediaViewState extends State { final _scrollCtrl = ScrollController(); @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { ref.listen(mediaProvider(widget.id), (_, s) { if (s.hasError) { SnackBarExtension.show(context, 'Failed to load media: ${s.error}'); } }); final media = ref.watch(mediaProvider(widget.id)); final toggleFavorite = () => ref.read(mediaProvider(widget.id).notifier).toggleFavorite(); return AdaptiveScaffold( floatingAction: media.value != null ? HidingFloatingActionButton( key: const Key('edit'), scrollCtrl: _scrollCtrl, child: MediaEditButton(media.value!), ) : null, child: switch (Theming.of(context).formFactor) { .phone => _CompactView( id: widget.id, coverUrl: widget.coverUrl, media: media, scrollCtrl: _scrollCtrl, toggleFavorite: toggleFavorite, ), .tablet => _LargeView( id: widget.id, coverUrl: widget.coverUrl, ref: ref, media: media, scrollCtrl: _scrollCtrl, toggleFavorite: toggleFavorite, ), }, ); }, ); } } class _CompactView extends StatefulWidget { const _CompactView({ required this.id, required this.coverUrl, required this.media, required this.scrollCtrl, required this.toggleFavorite, }); final int id; final String? coverUrl; final AsyncValue media; final ScrollController scrollCtrl; final Future Function() toggleFavorite; @override State<_CompactView> createState() => _CompactViewState(); } class _CompactViewState extends State<_CompactView> with SingleTickerProviderStateMixin { late final _tabCtrl = TabController(length: MediaHeader.tabsWithOverview.length, vsync: this); @override void dispose() { _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); final header = MediaHeader.withTabBar( id: widget.id, coverUrl: widget.coverUrl, media: widget.media.value, tabCtrl: _tabCtrl, scrollToTop: widget.scrollCtrl.scrollToTop, toggleFavorite: widget.toggleFavorite, ); return NestedScrollView( controller: widget.scrollCtrl, headerSliverBuilder: (context, _) => [header], body: MediaQuery( data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)), child: widget.media.unwrapPrevious().when( loading: () => const Center(child: Loader()), error: (_, _) => const Center(child: Text('Failed to load media')), data: (data) => _MediaTabs.withOverview(id: widget.id, media: data, tabCtrl: _tabCtrl), ), ), ); } } class _LargeView extends StatefulWidget { const _LargeView({ required this.id, required this.coverUrl, required this.ref, required this.media, required this.scrollCtrl, required this.toggleFavorite, }); final int id; final String? coverUrl; final WidgetRef ref; final AsyncValue media; final ScrollController scrollCtrl; final Future Function() toggleFavorite; @override State<_LargeView> createState() => _LargeViewState(); } class _LargeViewState extends State<_LargeView> with SingleTickerProviderStateMixin { late final _tabCtrl = TabController(length: MediaHeader.tabsWithoutOverview.length, vsync: this); @override void dispose() { _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final options = widget.ref.read(persistenceProvider.select((s) => s.options)); final header = MediaHeader.withoutTabBar( id: widget.id, coverUrl: widget.coverUrl, media: widget.media.value, toggleFavorite: widget.toggleFavorite, ); return DualPaneWithTabBar( tabCtrl: _tabCtrl, scrollToTop: widget.scrollCtrl.scrollToTop, tabs: MediaHeader.tabsWithoutOverview, leftPane: widget.media.unwrapPrevious().when( loading: () => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ header, const SliverFillRemaining(child: Center(child: Loader())), ], ), error: (_, _) => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ header, const SliverFillRemaining(child: Center(child: Text('Failed to load media'))), ], ), data: (data) => MediaOverviewSubview.withHeader( ref: widget.ref, info: data.info, header: header, highContrast: options.highContrast, ), ), rightPane: widget.media.unwrapPrevious().maybeWhen( data: (data) => _MediaTabs.withoutOverview( id: widget.id, media: data, tabCtrl: _tabCtrl, scrollCtrl: widget.scrollCtrl, ), orElse: () => const SizedBox(), ), ); } } /// When [withOverview], [_MediaTabs] requires a [NestedScrollView] ancestor. /// /// Due to [NestedScrollView] limitations, the custom [PagedController] /// can't be used here and has to be reimplemented temporarely on the inner /// scroll controller of the [NestedScrollView]. /// For more context: https://github.com/flutter/flutter/pull/104166. class _MediaTabs extends ConsumerStatefulWidget { const _MediaTabs.withOverview({required this.id, required this.media, required this.tabCtrl}) : withOverview = true, scrollCtrl = null; const _MediaTabs.withoutOverview({ required this.id, required this.media, required this.tabCtrl, required ScrollController this.scrollCtrl, }) : withOverview = false; final int id; final Media media; final TabController tabCtrl; final ScrollController? scrollCtrl; final bool withOverview; @override ConsumerState<_MediaTabs> createState() => __MediaSubViewState(); } class __MediaSubViewState extends ConsumerState<_MediaTabs> { late final _mediaActivitiesTag = MediaActivitiesTag(widget.id); late final ScrollController _scrollCtrl; double _lastMaxExtent = 0; @override void initState() { super.initState(); _scrollCtrl = widget.scrollCtrl ?? context.findAncestorStateOfType()!.innerController; _scrollCtrl.addListener(_scrollListener); widget.tabCtrl.addListener(_tabListener); } @override void deactivate() { // These pages are lazy-loaded and then kept alive until the media page is popped. ref.invalidate(mediaThreadsProvider(widget.id)); ref.invalidate(mediaFollowingProvider(widget.id)); ref.invalidate(activitiesProvider(_mediaActivitiesTag)); super.deactivate(); } @override void dispose() { _scrollCtrl.removeListener(_scrollListener); widget.tabCtrl.removeListener(_tabListener); super.dispose(); } void _tabListener() { _lastMaxExtent = 0; // This is a workaround for an issue with [NestedScrollView]. // If you switch to a tab with pagination, where the content // doesn't fill the view, the scroll controller has it's maximum // extent set to 0 and the loading of a next page of items is not triggered. // This is why we need to manually load the second page. if (!widget.tabCtrl.indexIsChanging && _scrollCtrl.hasClients) { final pos = _scrollCtrl.positions.last; if (pos.minScrollExtent == pos.maxScrollExtent) _loadNextPage(); } } void _scrollListener() { final pos = _scrollCtrl.positions.last; if (pos.pixels < pos.maxScrollExtent - 100) return; if (_lastMaxExtent == pos.maxScrollExtent) return; _lastMaxExtent = pos.maxScrollExtent; _loadNextPage(); } void _loadNextPage() { final index = widget.withOverview ? widget.tabCtrl.index : widget.tabCtrl.index + 1; if (index == MediaTab.threads.index) { ref.read(mediaThreadsProvider(widget.id).notifier).fetch(); } else if (index == MediaTab.following.index) { ref.read(mediaFollowingProvider(widget.id).notifier).fetch(); } else if (index == MediaTab.activities.index) { ref.read(activitiesProvider(_mediaActivitiesTag).notifier).fetch(); } else { ref .read(mediaConnectionsProvider(widget.id).notifier) .fetch(MediaTab.values.elementAt(index)); } } @override Widget build(BuildContext context) { ref.watch(mediaConnectionsProvider(widget.id).select((_) => null)); final viewerId = ref.watch(viewerIdProvider); final options = ref.watch(persistenceProvider.select((s) => s.options)); return TabBarView( controller: widget.tabCtrl, children: [ if (widget.withOverview) ConstrainedView( padded: false, child: MediaOverviewSubview.asFragment( ref: ref, info: widget.media.info, scrollCtrl: _scrollCtrl, highContrast: options.highContrast, ), ), MediaRelatedSubview( relations: widget.media.related, scrollCtrl: _scrollCtrl, invalidate: () => ref.invalidate(mediaProvider(widget.id)), highContrast: options.highContrast, ), MediaCharactersSubview( id: widget.id, scrollCtrl: _scrollCtrl, highContrast: options.highContrast, ), MediaStaffSubview( id: widget.id, scrollCtrl: _scrollCtrl, highContrast: options.highContrast, ), MediaReviewsSubview( id: widget.id, scrollCtrl: _scrollCtrl, bannerUrl: widget.media.info.banner, highContrast: options.highContrast, ), MediaThreadsSubview( id: widget.id, scrollCtrl: _scrollCtrl, highContrast: options.highContrast, analogClock: options.analogClock, ), MediaFollowingSubview( id: widget.id, scrollCtrl: _scrollCtrl, highContrast: options.highContrast, ), MediaActivitiesSubview( ref: ref, tag: _mediaActivitiesTag, scrollCtrl: _scrollCtrl, viewerId: viewerId, options: options, ), MediaRecommendationsSubview( id: widget.id, scrollCtrl: _scrollCtrl, rateRecommendation: ref .read(mediaConnectionsProvider(widget.id).notifier) .rateRecommendation, highContrast: options.highContrast, ), MediaStatsSubview( ref: ref, info: widget.media.info, stats: widget.media.stats, scrollCtrl: _scrollCtrl, highContrast: options.highContrast, ), ], ); } } ================================================ FILE: lib/feature/notification/notifications_filter_model.dart ================================================ enum NotificationsFilter { all('All'), replies('Replies'), activity('Activity'), forum('Forum'), airing('Airing'), follows('Follows'), media('Media'); const NotificationsFilter(this.label); final String label; List? get vars => switch (this) { NotificationsFilter.all => null, NotificationsFilter.replies => const [ 'ACTIVITY_MESSAGE', 'ACTIVITY_REPLY', 'ACTIVITY_REPLY_SUBSCRIBED', 'ACTIVITY_MENTION', 'THREAD_COMMENT_REPLY', 'THREAD_COMMENT_MENTION', 'THREAD_SUBSCRIBED', ], NotificationsFilter.activity => const [ 'ACTIVITY_MESSAGE', 'ACTIVITY_REPLY', 'ACTIVITY_REPLY_SUBSCRIBED', 'ACTIVITY_MENTION', 'ACTIVITY_LIKE', 'ACTIVITY_REPLY_LIKE', ], NotificationsFilter.forum => const [ 'THREAD_COMMENT_REPLY', 'THREAD_COMMENT_MENTION', 'THREAD_SUBSCRIBED', 'THREAD_LIKE', 'THREAD_COMMENT_LIKE', ], NotificationsFilter.airing => const ['AIRING'], NotificationsFilter.follows => const ['FOLLOWING'], NotificationsFilter.media => const [ 'RELATED_MEDIA_ADDITION', 'MEDIA_DATA_CHANGE', 'MEDIA_MERGE', 'MEDIA_DELETION', ], }; } ================================================ FILE: lib/feature/notification/notifications_filter_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/notification/notifications_filter_model.dart'; final notificationsFilterProvider = NotifierProvider.autoDispose( NotificationsFilterNotifier.new, ); class NotificationsFilterNotifier extends Notifier { @override NotificationsFilter build() => NotificationsFilter.all; @override NotificationsFilter get state => super.state; @override set state(NotificationsFilter newState) => super.state = newState; } ================================================ FILE: lib/feature/notification/notifications_model.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/extension/iterable_extension.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; enum NotificationType { following('Follows', 'FOLLOWING'), activityMention('Activity mentions', 'ACTIVITY_MENTION'), activityMessage('Messages', 'ACTIVITY_MESSAGE'), activityLike('Activity likes', 'ACTIVITY_LIKE'), activityReply('Activity replies', 'ACTIVITY_REPLY'), acrivityReplyLike('Activity reply likes', 'ACTIVITY_REPLY_LIKE'), activityReplySubscribed('Subscribed activity replies', 'ACTIVITY_REPLY_SUBSCRIBED'), threadLike('Thread likes', 'THREAD_LIKE'), threadReplySubscribed('Subscribed thread replies', 'THREAD_SUBSCRIBED'), threadCommentMention('Thread mentions', 'THREAD_COMMENT_MENTION'), threadCommentReply('Thread comments', 'THREAD_COMMENT_REPLY'), threadCommentLike('Thread comment likes', 'THREAD_COMMENT_LIKE'), airing('Episode releases', 'AIRING'), relatedMediaAddition('Related media additions', 'RELATED_MEDIA_ADDITION'), mediaDataChange('Media changes', 'MEDIA_DATA_CHANGE'), mediaMerge('Media merges', 'MEDIA_MERGE'), mediaDeletion('Media deletions', 'MEDIA_DELETION'), mediaSubmissionUpdate('Media submission updates', 'MEDIA_SUBMISSION_UPDATE'), staffSubmissionUpdate('Staff submission updates', 'STAFF_SUBMISSION_UPDATE'), characterSubmissionUpdate('Character submission updates', 'CHARACTER_SUBMISSION_UPDATE'); const NotificationType(this.label, this.value); final String label; final String value; static NotificationType? from(String? value) => NotificationType.values.firstWhereOrNull((v) => v.value == value); } sealed class SiteNotification { SiteNotification({ required Map map, required this.type, required this.imageUrl, required this.texts, }) : id = map['id'], createdAt = DateTimeExtension.fromSecondsSinceEpoch(map['createdAt'] ?? 0); static SiteNotification? maybe(Map map, ImageQuality imageQuality) { final type = NotificationType.from(map['type']); return switch (type) { null => null, .following => FollowNotification(map, type), .activityMention || .activityMessage || .activityLike || .activityReply || .acrivityReplyLike || .activityReplySubscribed => ActivityNotification(map, type), .threadLike => ThreadNotification(map, type), .threadReplySubscribed || .threadCommentMention || .threadCommentReply || .threadCommentLike => ThreadCommentNotification(map, type), .airing || .relatedMediaAddition => MediaReleaseNotification(map, type, imageQuality), .mediaDataChange || .mediaMerge => MediaChangeNotification(map, type, imageQuality), .mediaDeletion => MediaDeletionNotification(map, type), .mediaSubmissionUpdate => MediaSubmissionUpdateNotification(map, imageQuality), .characterSubmissionUpdate => CharacterSubmissionUpdateNotification(map, imageQuality), .staffSubmissionUpdate => StaffSubmissionUpdateNotification(map, imageQuality), }; } final int id; final NotificationType type; final DateTime createdAt; final String? imageUrl; final List texts; } class FollowNotification extends SiteNotification { FollowNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required this.userId, }); factory FollowNotification(Map map, NotificationType type) => FollowNotification._( map: map, type: type, imageUrl: map['user']?['avatar']?['large'], texts: [map['user']?['name'] ?? '?', ' followed you'], userId: map['user']?['id'] ?? 0, ); final int userId; } class ActivityNotification extends SiteNotification { ActivityNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required this.userId, required this.activityId, }); factory ActivityNotification(Map map, NotificationType type) { final List texts = switch (type) { .activityMention => [map['user']?['name'] ?? '?', ' mentioned you in an activity'], .activityMessage => [map['user']?['name'] ?? '?', ' sent you a message'], .activityLike => [map['user']?['name'] ?? '?', ' liked your activity'], .activityReply => [map['user']?['name'] ?? '?', ' replied to your activity'], .acrivityReplyLike => [map['user']?['name'] ?? '?', ' liked your reply'], .activityReplySubscribed => [ map['user']?['name'] ?? '?', ' replied to a subscribed activity', ], _ => const [], }; return ActivityNotification._( map: map, type: type, imageUrl: map['user']?['avatar']?['large'], texts: texts, userId: map['user']?['id'] ?? 0, activityId: map['activityId'] ?? 0, ); } final int userId; final int activityId; } class ThreadNotification extends SiteNotification { ThreadNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required this.userId, required this.threadId, required this.threadSiteUrl, }); factory ThreadNotification(Map map, NotificationType type) => ThreadNotification._( map: map, type: type, imageUrl: map['user']?['avatar']?['large'], texts: [map['user']?['name'] ?? '?', ' liked your thread ', map['thread']?['title'] ?? ''], userId: map['user']?['id'] ?? 0, threadId: map['thread']?['id'] ?? 0, threadSiteUrl: map['thread']?['siteUrl'], ); final int userId; final int threadId; final String? threadSiteUrl; } class ThreadCommentNotification extends SiteNotification { ThreadCommentNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required this.userId, required this.commentId, required this.commentSiteUrl, }); factory ThreadCommentNotification(Map map, NotificationType type) { final List texts = switch (type) { .threadReplySubscribed => [ map['user']?['name'] ?? '?', if (map['thread']?['title'] != null) ...[ ' commented in ', map['thread']['title'], ] else ' commented in a subscribed thread', ], .threadCommentMention => [ map['user']?['name'] ?? '?', if (map['thread']?['title'] != null) ...[ ' mentioned you in ', map['thread']['title'], ] else ' mentioned you in a subscribed thread', ], .threadCommentReply => [ map['user']?['name'] ?? '?', if (map['thread']?['title'] != null) ...[ ' replied to your comment in ', map['thread']['title'], ] else ' replied to your comment in a subscribed thread', ], .threadCommentLike => [ map['user']?['name'] ?? '?', if (map['thread']?['title'] != null) ...[ ' liked your comment in ', map['thread']['title'], ] else ' liked your comment in a subscribed thread', ], _ => const [], }; return ThreadCommentNotification._( map: map, type: type, imageUrl: map['user']?['avatar']?['large'], texts: texts, userId: map['user']?['id'] ?? 0, commentId: map['comment']?['id'] ?? 0, commentSiteUrl: map['comment']?['siteUrl'], ); } final int userId; final int commentId; final String? commentSiteUrl; } class MediaReleaseNotification extends SiteNotification { MediaReleaseNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required this.mediaId, }); factory MediaReleaseNotification( Map map, NotificationType type, ImageQuality imageQuality, ) { final List texts = switch (type) { .airing => [ map['media']?['title']?['userPreferred'] ?? '?', ' episode ', map['episode']?.toString() ?? '?', ' aired', ], .relatedMediaAddition => [ map['media']?['title']?['userPreferred'] ?? '?', ' got added to the site', ], _ => const [], }; return MediaReleaseNotification._( map: map, type: type, imageUrl: map['media']?['coverImage']?[imageQuality.value], texts: texts, mediaId: map['media']?['id'] ?? 0, ); } final int mediaId; } class MediaChangeNotification extends SiteNotification { MediaChangeNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required this.mediaId, required this.reason, }); factory MediaChangeNotification( Map map, NotificationType type, ImageQuality imageQuality, ) { final List texts = switch (type) { .mediaDataChange => [ map['media']?['title']?['userPreferred'] ?? '?', ' got site data changes', ], .mediaMerge => [ List.from(map['deletedMediaTitles'] ?? const [], growable: false).join(", "), ' got merged into ', map['media']?['title']?['userPreferred'] ?? '?', ], _ => const [], }; return MediaChangeNotification._( map: map, type: type, imageUrl: map['media']?['coverImage']?[imageQuality.value], texts: texts, mediaId: map['media']?['id'] ?? 0, reason: map['reason'] ?? '', ); } final int mediaId; final String reason; } class MediaDeletionNotification extends SiteNotification { MediaDeletionNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required this.reason, }); factory MediaDeletionNotification(Map map, NotificationType type) => MediaDeletionNotification._( map: map, type: type, imageUrl: null, texts: [map['deletedMediaTitle'] ?? '?', ' got deleted from the site'], reason: map['reason'] ?? '', ); final String reason; } sealed class SubmissionUpdateNotification extends SiteNotification { SubmissionUpdateNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required this.itemId, }) : notes = map['notes'] ?? ''; final int? itemId; final String notes; } class MediaSubmissionUpdateNotification extends SubmissionUpdateNotification { MediaSubmissionUpdateNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required super.itemId, }) : super._(); factory MediaSubmissionUpdateNotification(Map map, ImageQuality imageQuality) => MediaSubmissionUpdateNotification._( map: map, type: .mediaSubmissionUpdate, imageUrl: map['media']?['coverImage']?[imageQuality.value], texts: [ map['submittedTitle'] ?? map['media']?['title']?['userPreferred'] ?? '?', ' - submission ', map['status'] ?? '?', ], itemId: map['media']?['id'], ); } class CharacterSubmissionUpdateNotification extends SubmissionUpdateNotification { CharacterSubmissionUpdateNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required super.itemId, }) : super._(); factory CharacterSubmissionUpdateNotification( Map map, ImageQuality imageQuality, ) => CharacterSubmissionUpdateNotification._( map: map, type: .characterSubmissionUpdate, imageUrl: map['character']?['image']?[imageQuality.personValue], texts: [ map['character']?['name']?['userPreferred'] ?? '?', ' - submission ', map['status'] ?? '?', ], itemId: map['character']?['id'], ); } class StaffSubmissionUpdateNotification extends SubmissionUpdateNotification { StaffSubmissionUpdateNotification._({ required super.map, required super.type, required super.imageUrl, required super.texts, required super.itemId, }) : super._(); factory StaffSubmissionUpdateNotification(Map map, ImageQuality imageQuality) => StaffSubmissionUpdateNotification._( map: map, type: .staffSubmissionUpdate, imageUrl: map['staff']?['image']?[imageQuality.personValue], texts: [ map['staff']?['name']?['userPreferred'] ?? '?', ' - submission ', map['status'] ?? '?', ], itemId: map['staff']?['id'], ); } ================================================ FILE: lib/feature/notification/notifications_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/notification/notifications_filter_model.dart'; import 'package:otraku/feature/notification/notifications_filter_provider.dart'; import 'package:otraku/feature/notification/notifications_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final notificationsProvider = AsyncNotifierProvider.autoDispose>( NotificationsNotifier.new, ); class NotificationsNotifier extends AsyncNotifier> { late NotificationsFilter filter; @override FutureOr> build() async { filter = ref.watch(notificationsFilterProvider); return await _fetch(const PagedWithTotal()); } Future fetch() async { final oldState = state.value ?? const PagedWithTotal(); if (!oldState.hasNext) return; state = await AsyncValue.guard(() => _fetch(oldState)); } Future> _fetch(PagedWithTotal oldState) async { final data = await ref.read(repositoryProvider).request(GqlQuery.notifications, { 'page': oldState.next, if (filter == NotificationsFilter.all) ...{ 'withCount': true, 'resetCount': true, } else 'filter': filter.vars, }); final imageQuality = ref.read(persistenceProvider).options.imageQuality; int? unreadCount; if (filter.index < 1) { unreadCount = data['Viewer']['unreadNotificationCount'] ?? 0; } final items = []; for (final n in data['Page']['notifications']) { final item = SiteNotification.maybe(n, imageQuality); if (item != null) items.add(item); } return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false, unreadCount); } } ================================================ FILE: lib/feature/notification/notifications_view.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/notification/notifications_filter_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/feature/notification/notifications_filter_provider.dart'; import 'package:otraku/feature/notification/notifications_model.dart'; import 'package:otraku/feature/notification/notifications_provider.dart'; import 'package:otraku/util/background_handler.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/feature/edit/edit_view.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/input/pill_selector.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/widget/timestamp.dart'; class NotificationsView extends ConsumerStatefulWidget { const NotificationsView(); @override ConsumerState createState() => _NotificationsViewState(); } class _NotificationsViewState extends ConsumerState { late final _scrollCtrl = PagedController( loadMore: () => ref.read(notificationsProvider.notifier).fetch(), ); @override void initState() { super.initState(); BackgroundHandler.clearNotifications(); } @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final unreadCount = ref.watch(notificationsProvider.select((s) => s.value?.total ?? 0)); final filter = ref.watch(notificationsFilterProvider); final options = ref.watch(persistenceProvider.select((s) => s.options)); final content = _Content( unreadCount: unreadCount, analogClock: options.analogClock, highContrast: options.highContrast, scrollCtrl: _scrollCtrl, ); final formFactor = Theming.of(context).formFactor; return AdaptiveScaffold( topBar: const TopBar(title: 'Notifications'), floatingAction: formFactor == .phone ? HidingFloatingActionButton( key: const Key('filter'), scrollCtrl: _scrollCtrl, child: FloatingActionButton( tooltip: 'Filter', onPressed: _showFilterSheet, child: const Icon(Ionicons.funnel_outline), ), ) : null, child: formFactor == .phone ? content : Row( children: [ PillSelector( selected: filter.index, maxWidth: 120, onTap: (i) => ref.read(notificationsFilterProvider.notifier).state = NotificationsFilter.values[i], items: NotificationsFilter.values.map((v) => Text(v.label)).toList(), ), Expanded(child: content), ], ), ); } void _showFilterSheet() { showSheet( context, Consumer( builder: (context, ref, _) { final index = ref.read(notificationsFilterProvider.notifier).state.index; return SimpleSheet( initialHeight: PillSelector.expectedMinHeight(NotificationsFilter.values.length), builder: (context, scrollCtrl) => PillSelector( scrollCtrl: scrollCtrl, selected: index, onTap: (i) { ref.read(notificationsFilterProvider.notifier).state = NotificationsFilter.values[i]; Navigator.pop(context); }, items: NotificationsFilter.values.map((v) => Text(v.label)).toList(), ), ); }, ), ); } } class _Content extends StatelessWidget { const _Content({ required this.unreadCount, required this.analogClock, required this.highContrast, required this.scrollCtrl, }); final int unreadCount; final bool analogClock; final bool highContrast; final ScrollController scrollCtrl; @override Widget build(BuildContext context) { return PagedView( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(notificationsProvider), provider: notificationsProvider, onData: (data) => SliverList( delegate: SliverChildBuilderDelegate( (context, i) => _NotificationItem(data.items[i], i < unreadCount, analogClock, highContrast), childCount: data.items.length, ), ), ); } } class _NotificationItem extends StatelessWidget { const _NotificationItem(this.item, this.unread, this.analogClock, this.highContrast); final SiteNotification item; final bool unread; final bool analogClock; final bool highContrast; @override Widget build(BuildContext context) { final textTheme = TextTheme.of(context); final bodyMediumStyle = textTheme.bodyMedium!; final accentedStyle = bodyMediumStyle.copyWith(color: ColorScheme.of(context).primary); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelSmallLineHeight = context.lineHeight(textTheme.labelSmall!); final height = bodyMediumLineHeight * 2 + max(labelSmallLineHeight, Theming.iconSmall) + 23; return SizedBox( height: height + 10, child: CardExtension.highContrast(highContrast)( margin: const .only(bottom: Theming.offset), child: Row( children: [ if (item.imageUrl != null) GestureDetector( behavior: .opaque, onTap: () => switch (item) { FollowNotification item => context.push(Routes.user(item.userId, item.imageUrl)), ActivityNotification item => context.push( Routes.user(item.userId, item.imageUrl), ), ThreadNotification item => context.push(Routes.user(item.userId, item.imageUrl)), ThreadCommentNotification item => context.push( Routes.user(item.userId, item.imageUrl), ), MediaReleaseNotification item => context.push( Routes.media(item.mediaId, item.imageUrl), ), MediaChangeNotification item => context.push( Routes.media(item.mediaId, item.imageUrl), ), MediaDeletionNotification _ => null, MediaSubmissionUpdateNotification item => item.itemId != null ? context.push(Routes.media(item.itemId!)) : null, CharacterSubmissionUpdateNotification item => item.itemId != null ? context.push(Routes.character(item.itemId!)) : null, StaffSubmissionUpdateNotification item => item.itemId != null ? context.push(Routes.staff(item.itemId!)) : null, }, onLongPress: () => switch (item) { MediaReleaseNotification item => showSheet( context, EditView((id: item.mediaId, setComplete: false)), ), MediaChangeNotification item => showSheet( context, EditView((id: item.mediaId, setComplete: false)), ), _ => null, }, child: ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: CachedImage(item.imageUrl!, width: height / Theming.coverHtoWRatio), ), ), Flexible( child: GestureDetector( behavior: .opaque, onTap: () => switch (item) { FollowNotification item => context.push(Routes.user(item.userId, item.imageUrl)), ActivityNotification item => context.push(Routes.activity(item.activityId)), ThreadNotification item => context.push(Routes.thread(item.threadId)), ThreadCommentNotification item => context.push(Routes.comment(item.commentId)), MediaReleaseNotification item => context.push( Routes.media(item.mediaId, item.imageUrl), ), MediaChangeNotification _ || MediaDeletionNotification _ || MediaSubmissionUpdateNotification _ || CharacterSubmissionUpdateNotification _ || StaffSubmissionUpdateNotification _ => showDialog( context: context, builder: (context) => _NotificationDialog(item), ), }, onLongPress: () => switch (item) { MediaReleaseNotification item => showSheet( context, EditView((id: item.mediaId, setComplete: false)), ), MediaChangeNotification item => showSheet( context, EditView((id: item.mediaId, setComplete: false)), ), _ => null, }, child: Padding( padding: Theming.paddingAll, child: Column( mainAxisAlignment: .spaceEvenly, crossAxisAlignment: .stretch, spacing: 3, children: [ Flexible( child: Text.rich( overflow: .ellipsis, maxLines: 2, TextSpan( children: [ for (int i = 0; i < item.texts.length; i++) TextSpan( text: item.texts[i], style: (i % 2 == 0) ? accentedStyle : bodyMediumStyle, ), ], ), ), ), Timestamp(item.createdAt, analogClock), ], ), ), ), ), if (unread) Container( height: height, width: Theming.offset, decoration: BoxDecoration( color: ColorScheme.of(context).primary, borderRadius: const BorderRadius.horizontal(right: Theming.radiusSmall), ), ), ], ), ), ); } } class _NotificationDialog extends StatelessWidget { const _NotificationDialog(this.item); final SiteNotification item; @override Widget build(BuildContext context) { final bodyMediumStyle = TextTheme.of(context).bodyMedium!; final accentedStyle = bodyMediumStyle.copyWith(color: ColorScheme.of(context).primary); final imageHeight = context.lineHeight(bodyMediumStyle) * 6; return DialogBox( Padding( padding: const EdgeInsetsGeometry.symmetric( vertical: Theming.offset, horizontal: Theming.offset * 2, ), child: Column( mainAxisSize: .min, crossAxisAlignment: .stretch, spacing: Theming.offset, children: [ if (item.imageUrl != null) Center( child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage( item.imageUrl!, height: imageHeight, width: imageHeight / Theming.coverHtoWRatio, ), ), ), Text.rich( overflow: .ellipsis, TextSpan( children: [ for (int i = 0; i < item.texts.length; i++) TextSpan( text: item.texts[i], style: (i % 2 == 0) ? accentedStyle : bodyMediumStyle, ), ], ), ), ?switch (item) { MediaChangeNotification item => HtmlContent(item.reason), MediaDeletionNotification item => HtmlContent(item.reason), SubmissionUpdateNotification item => Text(item.notes), _ => null, }, ], ), ), ); } } ================================================ FILE: lib/feature/review/review_grid.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/review/review_models.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; class ReviewGrid extends StatelessWidget { const ReviewGrid(this.items, this.highContrast); final List items; final bool highContrast; @override Widget build(BuildContext context) { final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final labelMediumLineHeight = context.lineHeight(TextTheme.of(context).labelMedium!); final detailsHeight = max( labelMediumLineHeight * 2, labelMediumLineHeight + Theming.iconSmall + 5, ); final textHeight = bodyMediumLineHeight * 2 + detailsHeight + 15; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight( minWidth: 270, height: textHeight + 100, ), delegate: SliverChildBuilderDelegate( (_, i) => _Tile(items[i], highContrast), childCount: items.length, ), ); } } class _Tile extends StatelessWidget { const _Tile(this.item, this.highContrast); final ReviewItem item; final bool highContrast; @override Widget build(BuildContext context) { return CardExtension.highContrast(highContrast)( child: InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => context.push(Routes.review(item.id, item.bannerUrl)), child: Column( crossAxisAlignment: .stretch, children: [ SizedBox( height: 100, child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall), child: item.bannerUrl != null ? Hero(tag: item.id, child: CachedImage(item.bannerUrl!)) : DecoratedBox( decoration: BoxDecoration( color: ColorScheme.of(context).surfaceContainerHighest, ), ), ), ), Expanded( child: Padding( padding: .symmetric(horizontal: Theming.offset, vertical: 5), child: Column( crossAxisAlignment: .stretch, mainAxisAlignment: MainAxisAlignment.spaceEvenly, spacing: 5, children: [ Text( 'Review of ${item.mediaTitle} by ${item.userName}', style: TextTheme.of(context).bodyMedium, overflow: .ellipsis, maxLines: 2, ), Row( mainAxisAlignment: .spaceBetween, children: [ Expanded( child: Text( item.summary, style: TextTheme.of(context).labelMedium, overflow: .ellipsis, maxLines: 2, ), ), Padding( padding: const .symmetric(horizontal: 5), child: Column( mainAxisAlignment: .center, spacing: 5, children: [ const Icon(Icons.thumb_up_outlined, size: Theming.iconSmall), Text(item.rating, style: TextTheme.of(context).labelMedium), ], ), ), ], ), ], ), ), ), ], ), ), ); } } ================================================ FILE: lib/feature/review/review_header.dart ================================================ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/review/review_models.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/widget/layout/content_header.dart'; class ReviewHeader extends StatelessWidget { const ReviewHeader({required this.id, required this.review, required this.bannerUrl}); final int id; final Review? review; final String? bannerUrl; @override Widget build(BuildContext context) { return CustomContentHeader( title: review?.mediaTitle, siteUrl: review?.siteUrl, bannerUrl: review?.banner ?? bannerUrl, content: PreferredSize( preferredSize: const Size.fromHeight(100), child: Column( crossAxisAlignment: .stretch, children: review != null ? [ Flexible( child: GestureDetector( onTap: () => context.push(Routes.media(review!.mediaId, review!.mediaCover)), child: Text( review!.mediaTitle, overflow: .fade, textAlign: .center, style: TextTheme.of(context).bodyMedium, ), ), ), Flexible( child: GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.user(review!.userId, review!.userAvatar)), child: Text.rich( textAlign: .center, TextSpan( style: TextTheme.of(context).bodyMedium, children: [ TextSpan(text: 'review by ', style: TextTheme.of(context).labelMedium), TextSpan(text: review!.userName), ], ), ), ), ), ] : const [], ), ), ); } } ================================================ FILE: lib/feature/review/review_models.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/util/markdown.dart'; import 'package:otraku/feature/media/media_models.dart'; class ReviewItem { ReviewItem._({ required this.id, required this.mediaTitle, required this.userName, required this.summary, required this.rating, required this.bannerUrl, }); factory ReviewItem(Map map) => ReviewItem._( id: map['id'], mediaTitle: map['media']['title']['userPreferred'], userName: map['user']['name'], summary: map['summary'], rating: '${map['rating']}/${map['ratingAmount']}', bannerUrl: map['media']['bannerImage'], ); final int id; final String mediaTitle; final String userName; final String summary; final String rating; final String? bannerUrl; } class Review { Review._({ required this.id, required this.userId, required this.mediaId, required this.userName, required this.userAvatar, required this.mediaTitle, required this.mediaCover, required this.banner, required this.summary, required this.text, required this.createdAt, required this.siteUrl, required this.score, required this.rating, required this.totalRating, required this.viewerRating, }); factory Review(Map map, ImageQuality imageQuality, bool analogClock) => Review._( id: map['id'], userId: map['user']['id'], mediaId: map['media']['id'], userName: map['user']['name'] ?? '', userAvatar: map['user']['avatar']['large'], mediaTitle: map['media']['title']['userPreferred'] ?? '', mediaCover: map['media']['coverImage'][imageQuality.value], banner: map['media']['bannerImage'], summary: map['summary'] ?? '', text: parseMarkdown(map['body'] ?? ''), createdAt: DateTimeExtension.fromSecondsSinceEpoch( map['createdAt'], ).formattedDateTimeFromSeconds(analogClock), siteUrl: map['siteUrl'], score: map['score'] ?? 0, rating: map['rating'] ?? 0, totalRating: map['ratingAmount'] ?? 0, viewerRating: map['userRating'] == 'UP_VOTE' ? true : map['userRating'] == 'DOWN_VOTE' ? false : null, ); final int id; final int userId; final int mediaId; final String userName; final String? userAvatar; final String mediaTitle; final String? mediaCover; final String? banner; final String summary; final String text; final String createdAt; final String siteUrl; final int score; int rating; int totalRating; bool? viewerRating; } class ReviewsFilter { const ReviewsFilter({this.mediaType, this.sort = .createdAtDesc}); final MediaType? mediaType; final ReviewsSort sort; ReviewsFilter copyWith({(MediaType?,)? mediaType, ReviewsSort? sort}) => ReviewsFilter( mediaType: mediaType == null ? this.mediaType : mediaType.$1, sort: sort ?? this.sort, ); } enum ReviewsSort { createdAtDesc('Newest', 'CREATED_AT_DESC'), createdAt('Oldest', 'CREATED_AT'), ratingDesc('Highest Rated', 'RATING_DESC'), rating('Lowest Rated', 'RATING'); const ReviewsSort(this.label, this.value); final String label; final String value; } ================================================ FILE: lib/feature/review/review_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/feature/review/review_models.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final reviewProvider = AsyncNotifierProvider.autoDispose.family( ReviewNotifier.new, ); class ReviewNotifier extends AsyncNotifier { ReviewNotifier(this.arg); final int arg; @override FutureOr build() async { final data = await ref.read(repositoryProvider).request(GqlQuery.review, {'id': arg}); final options = ref.watch(persistenceProvider.select((s) => s.options)); return Review(data['Review'], options.imageQuality, options.analogClock); } Future rate(bool? rating) { return ref.read(repositoryProvider).request(GqlMutation.rateReview, { 'id': arg, 'rating': rating == null ? 'NO_VOTE' : rating ? 'UP_VOTE' : 'DOWN_VOTE', }).getErrorOrNull(); } } ================================================ FILE: lib/feature/review/review_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/feature/review/review_header.dart'; import 'package:otraku/feature/review/review_models.dart'; import 'package:otraku/feature/review/review_provider.dart'; import 'package:otraku/widget/html_content.dart'; class ReviewView extends StatelessWidget { const ReviewView(this.id, this.bannerUrl); final int id; final String? bannerUrl; @override Widget build(BuildContext context) { return Scaffold( body: Consumer( builder: (context, ref, _) { final data = ref.watch(reviewProvider(id).select((s) => s.value)); return CustomScrollView( slivers: [ ReviewHeader(id: id, review: data, bannerUrl: bannerUrl), if (data != null) ...[ SliverConstrainedView( sliver: SliverToBoxAdapter( child: Text( data.summary, style: TextTheme.of(context).labelMedium, textAlign: .center, ), ), ), SliverConstrainedView( sliver: HtmlContent(data.text, renderMode: RenderMode.sliverList), ), SliverToBoxAdapter( child: Center( child: Container( margin: Theming.paddingAll, padding: Theming.paddingAll, decoration: BoxDecoration( color: ColorScheme.of(context).primary, borderRadius: Theming.borderRadiusBig, ), child: Text( '${data.score}/100', style: TextTheme.of( context, ).bodyMedium?.copyWith(color: ColorScheme.of(context).onPrimary), ), ), ), ), _RateButtons(data, ref.read(reviewProvider(id).notifier).rate), SliverPadding( padding: .only( top: 20, bottom: MediaQuery.viewPaddingOf(context).bottom + Theming.offset, ), sliver: SliverToBoxAdapter( child: Text( data.createdAt, style: TextTheme.of(context).labelMedium, textAlign: .center, ), ), ), ], ], ); }, ), ); } } class _RateButtons extends StatefulWidget { const _RateButtons(this.review, this.rate); final Review review; final Future Function(bool?) rate; @override _RateButtonsState createState() => _RateButtonsState(); } class _RateButtonsState extends State<_RateButtons> { @override Widget build(BuildContext context) { final review = widget.review; return SliverToBoxAdapter( child: Column( mainAxisSize: .min, children: [ Row( mainAxisAlignment: .center, children: [ IconButton( icon: Icon(review.viewerRating == true ? Icons.thumb_up : Icons.thumb_up_outlined), color: review.viewerRating == true ? ColorScheme.of(context).primary : null, onPressed: () => _rate(review.viewerRating != true ? true : null), ), IconButton( icon: Icon( review.viewerRating == false ? Icons.thumb_down : Icons.thumb_down_outlined, ), color: review.viewerRating == false ? ColorScheme.of(context).error : null, onPressed: () => _rate(review.viewerRating != false ? false : null), ), ], ), Text( '${review.rating}/${review.totalRating} users liked this review', style: TextTheme.of(context).labelMedium, textAlign: .center, ), ], ), ); } void _rate(bool? rating) async { final review = widget.review; final oldRating = review.rating; final oldTotalRating = review.totalRating; final oldViewerRating = review.viewerRating; setState(() { if (rating == null) { if (oldViewerRating == true) { review.rating--; } review.totalRating--; } else if (rating) { if (oldViewerRating == null) { review.totalRating++; } review.rating++; } else { if (oldViewerRating == null) { review.totalRating++; } else { review.rating--; } } review.viewerRating = rating; }); final err = await widget.rate(rating); if (err == null) return; setState(() { review.rating = oldRating; review.totalRating = oldTotalRating; review.viewerRating = oldViewerRating; }); if (mounted) SnackBarExtension.show(context, err.toString()); } } ================================================ FILE: lib/feature/review/reviews_filter_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/review/review_models.dart'; final reviewsFilterProvider = NotifierProvider.autoDispose .family(ReviewsFilterNotifier.new); class ReviewsFilterNotifier extends Notifier { ReviewsFilterNotifier(this.arg); final int arg; @override ReviewsFilter build() => const ReviewsFilter(); @override set state(ReviewsFilter newState) => super.state = newState; } ================================================ FILE: lib/feature/review/reviews_filter_sheet.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/review/review_models.dart'; Future showReviewsFilterSheet({ required BuildContext context, required ReviewsFilter filter, required void Function(ReviewsFilter) onDone, required bool highContrast, }) { return showSheet( context, SimpleSheet( initialHeight: Theming.minTapTarget * 3.5, builder: (context, scrollCtrl) => ListView( controller: scrollCtrl, physics: Theming.bouncyPhysics, padding: const .symmetric(horizontal: Theming.offset, vertical: 20), children: [ ChipSelector.ensureSelected( title: 'Sort', items: ReviewsSort.values.map((v) => (v.label, v)).toList(), value: filter.sort, onChanged: (v) => filter = filter.copyWith(sort: v), highContrast: highContrast, ), ChipSelector( title: 'Media Type', items: MediaType.values.map((v) => (v.label, v)).toList(), value: filter.mediaType, onChanged: (v) => filter = filter.copyWith(mediaType: (v,)), highContrast: highContrast, ), ], ), ), ).then((_) => onDone(filter)); } ================================================ FILE: lib/feature/review/reviews_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; import 'package:otraku/feature/review/review_models.dart'; import 'package:otraku/feature/review/reviews_filter_provider.dart'; final reviewsProvider = AsyncNotifierProvider.autoDispose .family, int>(ReviewsNotifier.new); class ReviewsNotifier extends AsyncNotifier> { ReviewsNotifier(this.arg); final int arg; late ReviewsFilter filter; @override FutureOr> build() { filter = ref.watch(reviewsFilterProvider(arg)); return _fetch(const PagedWithTotal()); } Future fetch() async { final oldState = state.value ?? const PagedWithTotal(); if (!oldState.hasNext) return; state = await AsyncValue.guard(() => _fetch(oldState)); } Future> _fetch(PagedWithTotal oldState) async { final data = await ref.read(repositoryProvider).request(GqlQuery.reviewPage, { 'userId': arg, 'page': oldState.next, 'sort': filter.sort.value, if (filter.mediaType != null) 'mediaType': filter.mediaType!.value, }); final items = []; for (final r in data['Page']['reviews']) { items.add(ReviewItem(r)); } return oldState.withNext( items, data['Page']['pageInfo']['hasNextPage'] ?? false, data['Page']['pageInfo']['total'] ?? oldState.total, ); } } ================================================ FILE: lib/feature/review/reviews_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/review/review_models.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/feature/review/review_grid.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/feature/review/reviews_filter_sheet.dart'; import 'package:otraku/feature/review/reviews_provider.dart'; import 'package:otraku/feature/review/reviews_filter_provider.dart'; class ReviewsView extends ConsumerStatefulWidget { const ReviewsView(this.id); final int id; @override ConsumerState createState() => _ReviewsViewState(); } class _ReviewsViewState extends ConsumerState { late final _ctrl = PagedController( loadMore: () => ref.read(reviewsProvider(widget.id).notifier).fetch(), ); @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final options = ref.watch(persistenceProvider.select((s) => s.options)); final count = ref.watch(reviewsProvider(widget.id).select((s) => s.value?.total ?? 0)); return AdaptiveScaffold( topBar: TopBar( title: 'Reviews', trailing: [ if (count > 0) Padding( padding: const .only(right: Theming.offset), child: Text(count.toString(), style: TextTheme.of(context).titleSmall), ), ], ), floatingAction: HidingFloatingActionButton( key: const Key('filter'), scrollCtrl: _ctrl, child: FloatingActionButton( tooltip: 'Filter', child: const Icon(Ionicons.funnel_outline), onPressed: () => showReviewsFilterSheet( context: context, filter: ref.read(reviewsFilterProvider(widget.id)), onDone: (filter) => ref.read(reviewsFilterProvider(widget.id).notifier).state = filter, highContrast: options.highContrast, ), ), ), child: PagedView( scrollCtrl: _ctrl, onRefresh: (invalidate) => invalidate(reviewsProvider(widget.id)), provider: reviewsProvider(widget.id), onData: (data) => ReviewGrid(data.items, options.highContrast), ), ); } } ================================================ FILE: lib/feature/settings/settings_about_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; class SettingsAboutSubview extends StatelessWidget { const SettingsAboutSubview(this.scrollCtrl); final ScrollController scrollCtrl; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { final padding = MediaQuery.paddingOf(context); final persistence = ref.watch(persistenceProvider); final lastBackgroundJob = persistence.appMeta.lastBackgroundJob; final lastJobTimestamp = lastBackgroundJob?.formattedDateTimeFromSeconds( persistence.options.analogClock, ); return Align( alignment: Alignment.center, child: ListView( controller: scrollCtrl, padding: .only( top: padding.top + Theming.offset, bottom: padding.bottom + Theming.offset, ), children: [ Image.asset( 'assets/icons/about.png', color: ColorScheme.of(context).primary, width: 180, height: 180, ), Padding( padding: const .symmetric(vertical: 5), child: Text( 'Otraku - v.$appVersion', textAlign: .center, style: TextTheme.of(context).bodyMedium, ), ), const Text('An unofficial AniList app', textAlign: .center), const SizedBox(height: 30), ListTile( leading: const Icon(Ionicons.logo_discord), title: const Text('Discord'), onTap: () => SnackBarExtension.launch(context, 'https://discord.gg/YN2QWVbFef'), ), ListTile( leading: const Icon(Ionicons.logo_github), title: const Text('Source Code'), onTap: () => SnackBarExtension.launch(context, 'https://github.com/lotusprey/otraku'), ), ListTile( leading: const Icon(Ionicons.cash_outline), title: const Text('Donate'), onTap: () => SnackBarExtension.launch(context, 'https://ko-fi.com/lotusgate'), ), ListTile( leading: const Icon(Ionicons.finger_print), title: const Text('Privacy Policy'), onTap: () => SnackBarExtension.launch( context, 'https://sites.google.com/view/otraku/privacy-policy', ), ), const ListTile( leading: Icon(Ionicons.trash_bin_outline), title: Text('Clear Image Cache'), onTap: clearImageCache, ), ListTile( leading: Icon(Ionicons.refresh_outline), title: Text('Reset Options'), onTap: () => ref.read(persistenceProvider.notifier).setOptions(.empty()), ), if (lastJobTimestamp != null) ...[ Padding( padding: const .only(left: Theming.offset, right: Theming.offset, top: 20), child: Text( 'Performed a notification check around $lastJobTimestamp.', style: TextTheme.of(context).labelMedium, textAlign: .center, ), ), ], ], ), ); }, ); } } ================================================ FILE: lib/feature/settings/settings_app_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/input/stateful_tiles.dart'; import 'package:otraku/feature/discover/discover_model.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/feature/home/home_model.dart'; import 'package:otraku/feature/settings/theme_preview.dart'; class SettingsAppSubview extends ConsumerWidget { const SettingsAppSubview(this.scrollCtrl); final ScrollController scrollCtrl; @override Widget build(BuildContext context, WidgetRef ref) { final listPadding = MediaQuery.paddingOf(context); const tilePadding = EdgeInsets.only( bottom: Theming.offset, left: Theming.offset, right: Theming.offset, ); final options = ref.watch(persistenceProvider.select((s) => s.options)); final update = (Options options) => ref.read(persistenceProvider.notifier).setOptions(options); return ListView( controller: scrollCtrl, padding: .only( top: listPadding.top + Theming.offset, bottom: listPadding.bottom + Theming.offset + 60, ), children: [ ExpansionTile( title: const Text('Appearance'), initiallyExpanded: true, expandedCrossAxisAlignment: .stretch, children: [ Padding( padding: const .only( bottom: Theming.offset, left: Theming.offset, right: Theming.offset, ), child: StatefulSegmentedButton( segments: const [ ButtonSegment( value: ThemeMode.system, label: Text('System'), icon: Icon(Icons.sync_outlined), ), ButtonSegment( value: ThemeMode.light, label: Text('Light'), icon: Icon(Icons.wb_sunny_outlined), ), ButtonSegment( value: ThemeMode.dark, label: Text('Dark'), icon: Icon(Icons.mode_night_outlined), ), ], value: options.themeMode, onChanged: (themeMode) => update(options.copyWith(themeMode: themeMode)), ), ), ThemePreview(ref: ref, options: options), const SizedBox(height: Theming.offset / 2), StatefulSwitchListTile( title: const Text('High Contrast'), subtitle: const Text('Pure backgrounds & outlined cards'), value: options.highContrast, onChanged: (v) => update(options.copyWith(highContrast: v)), ), const SizedBox(height: Theming.offset / 2), Padding( padding: const .only( left: Theming.offset, right: Theming.offset, bottom: Theming.offset, ), child: const Text('Button Orientation'), ), Padding( padding: const .only( left: Theming.offset, right: Theming.offset, bottom: Theming.offset, ), child: StatefulSegmentedButton( segments: const [ ButtonSegment( value: ButtonOrientation.auto, label: Text('Auto'), icon: Icon(Icons.align_horizontal_center_rounded), ), ButtonSegment( value: ButtonOrientation.left, label: Text('Left'), icon: Icon(Icons.align_horizontal_left_rounded), ), ButtonSegment( value: ButtonOrientation.right, label: Text('Right'), icon: Icon(Icons.align_horizontal_right_rounded), ), ], value: options.buttonOrientation, onChanged: (buttonOrientation) => update(options.copyWith(buttonOrientation: buttonOrientation)), ), ), ], ), ExpansionTile( title: const Text('Collection Previews'), children: [ StatefulSwitchListTile( title: const Text('Anime Collection Preview'), subtitle: const Text( 'Only load your watched/rewatched anime ' 'and expand to full collection with the floating button', ), value: options.animeCollectionPreview, onChanged: (v) => update(options.copyWith(animeCollectionPreview: v)), ), StatefulSwitchListTile( title: const Text('Manga Collection Preview'), subtitle: const Text( 'Only load your read/reread manga ' 'and expand to full collection with the floating button', ), value: options.mangaCollectionPreview, onChanged: (v) => update(options.copyWith(mangaCollectionPreview: v)), ), ], ), ExpansionTile( title: const Text('Defaults'), children: [ Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Home Tab', items: HomeTab.values.map((v) => (v.label, v)).toList(), value: options.homeTab, onChanged: (v) => update(options.copyWith(homeTab: v)), highContrast: options.highContrast, ), ), Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Discover Type', items: DiscoverType.values.map((v) => (v.label, v)).toList(), value: options.discoverType, onChanged: (v) => update(options.copyWith(discoverType: v)), highContrast: options.highContrast, ), ), Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Image Quality', items: ImageQuality.values.map((v) => (v.label, v)).toList(), value: options.imageQuality, onChanged: (v) => update(options.copyWith(imageQuality: v)), highContrast: options.highContrast, ), ), ], ), ExpansionTile( title: const Text('View Layouts'), children: [ Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Discover View', items: const [ ('Detailed', DiscoverItemView.detailed), ('Simple', DiscoverItemView.simple), ], value: options.discoverItemView, onChanged: (v) => update(options.copyWith(discoverItemView: v)), highContrast: options.highContrast, ), ), Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Collection View', items: const [ ('Detailed', CollectionItemView.detailed), ('Simple', CollectionItemView.simple), ], value: options.collectionItemView, onChanged: (v) => update(options.copyWith(collectionItemView: v)), highContrast: options.highContrast, ), ), Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Collection Preview View', items: const [ ('Detailed', CollectionItemView.detailed), ('Simple', CollectionItemView.simple), ], value: options.collectionPreviewItemView, onChanged: (v) => update(options.copyWith(collectionPreviewItemView: v)), highContrast: options.highContrast, ), ), ], ), StatefulSwitchListTile( title: const Text('12 Hour Clock'), value: options.analogClock, onChanged: (v) => update(options.copyWith(analogClock: v)), ), StatefulSwitchListTile( title: const Text('Confirm Exit'), value: options.confirmExit, onChanged: (v) => update(options.copyWith(confirmExit: v)), ), ], ); } } ================================================ FILE: lib/feature/settings/settings_content_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/input/stateful_tiles.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/settings/settings_model.dart'; import 'package:otraku/widget/sheets.dart'; class SettingsContentSubview extends StatelessWidget { const SettingsContentSubview(this.scrollCtrl, this.settings, this.highContrast); final ScrollController scrollCtrl; final Settings settings; final bool highContrast; @override Widget build(BuildContext context) { final listPadding = MediaQuery.paddingOf(context); const tilePadding = EdgeInsets.only( bottom: Theming.offset, left: Theming.offset, right: Theming.offset, ); final sheetInitialHeight = MediaQuery.sizeOf(context).height; return ListView( controller: scrollCtrl, padding: .only( top: listPadding.top + Theming.offset, bottom: listPadding.bottom + Theming.offset, ), children: [ ExpansionTile( title: const Text('Media'), initiallyExpanded: true, children: [ Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Title Language', items: TitleLanguage.values.map((v) => (v.label, v)).toList(), value: settings.titleLanguage, onChanged: (v) => settings.titleLanguage = v, highContrast: highContrast, ), ), Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Character & Staff Names', items: PersonNaming.values.map((v) => (v.label, v)).toList(), value: settings.personNaming, onChanged: (v) => settings.personNaming = v, highContrast: highContrast, ), ), Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Activity Merge Time', items: const [ ('Never', 0), ('30 Minutes', 30), ('1 Hour', 60), ('2 Hours', 120), ('3 Hours', 180), ('6 Hours', 360), ('12 Hours', 720), ('1 Day', 1440), ('2 Days', 2880), ('3 Days', 4320), ('1 Week', 10080), ('2 Weeks', 20160), ('Always', 29160), ], value: settings.activityMergeTime, onChanged: (v) => settings.activityMergeTime = v, highContrast: highContrast, ), ), StatefulSwitchListTile( title: const Text('18+ Content'), value: settings.displayAdultContent, onChanged: (val) => settings.displayAdultContent = val, ), StatefulSwitchListTile( title: const Text('Airing Anime Notifications'), value: settings.airingNotifications, onChanged: (val) => settings.airingNotifications = val, ), ], ), ExpansionTile( title: const Text('Lists'), initiallyExpanded: true, children: [ Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Scoring System', items: ScoreFormat.values.map((v) => (v.label, v)).toList(), value: settings.scoreFormat, onChanged: (v) => settings.scoreFormat = v, highContrast: highContrast, ), ), Padding( padding: tilePadding, child: ChipSelector.ensureSelected( title: 'Default Site List Sort', items: EntrySort.rowOrders.map((v) => (v.label, v)).toList(), value: settings.defaultSort, onChanged: (v) => settings.defaultSort = v, highContrast: highContrast, ), ), StatefulCheckboxListTile( title: const Text('Split Completed Anime'), value: settings.splitCompletedAnime, onChanged: (val) => settings.splitCompletedAnime = val!, ), ListTile( title: const Text('Anime Custom Lists'), leading: const Icon(Ionicons.film_outline), onTap: () => showSheet( context, SimpleSheet( initialHeight: sheetInitialHeight, builder: (context, scrollCtrl) => _ListManagement( title: 'Anime Custom Lists', label: 'Anime custom list', items: settings.animeCustomLists, scrollCtrl: scrollCtrl, ), ), ), ), StatefulCheckboxListTile( title: const Text('Split Completed Manga'), value: settings.splitCompletedManga, onChanged: (val) => settings.splitCompletedManga = val!, ), ListTile( title: const Text('Manga Custom Lists'), leading: const Icon(Ionicons.book_outline), onTap: () => showSheet( context, SimpleSheet( initialHeight: sheetInitialHeight, builder: (context, scrollCtrl) => _ListManagement( title: 'Manga Custom Lists', label: 'Manga custom list', items: settings.mangaCustomLists, scrollCtrl: scrollCtrl, ), ), ), ), StatefulSwitchListTile( title: const Text('Advanced Scoring'), value: settings.advancedScoringEnabled, onChanged: (val) => settings.advancedScoringEnabled = val, ), ListTile( title: const Text('Advanced Score Sections'), leading: const Icon(Ionicons.star_half), onTap: () => showSheet( context, SimpleSheet( initialHeight: sheetInitialHeight, builder: (context, scrollCtrl) => _ListManagement( title: 'Advanced Score Sections', label: 'Advanced score section', items: settings.advancedScoreSections, scrollCtrl: scrollCtrl, ), ), ), ), ], ), ExpansionTile( title: const Text('Social'), initiallyExpanded: true, expandedCrossAxisAlignment: .stretch, children: [ for (final e in settings.disabledListActivity.entries) StatefulCheckboxListTile( title: Text('Create ${e.key.label(null)} Activities'), value: !e.value, onChanged: (val) => settings.disabledListActivity[e.key] = !val!, ), StatefulSwitchListTile( title: const Text('Limit Messages'), subtitle: const Text('Only users I follow can message me'), value: settings.restrictMessagesToFollowing, onChanged: (val) => settings.restrictMessagesToFollowing = val, ), ], ), ], ); } } class _ListManagement extends StatefulWidget { const _ListManagement({ required this.title, required this.label, required this.items, required this.scrollCtrl, }); final String title; final String label; final List items; final ScrollController scrollCtrl; @override State<_ListManagement> createState() => _ListManagementState(); } class _ListManagementState extends State<_ListManagement> { @override Widget build(BuildContext context) { final items = widget.items; return Column( mainAxisSize: .min, crossAxisAlignment: .stretch, children: [ Padding( padding: const .only(top: Theming.offset), child: Row( children: [ Padding( padding: const .symmetric(horizontal: Theming.offset), child: Text(widget.title, style: TextTheme.of(context).bodyMedium), ), const Spacer(), IconButton( tooltip: 'Add', icon: const Icon(Icons.add_rounded), onPressed: () async { final newItem = await showDialog( context: context, builder: (context) => TextInputDialog( title: widget.label, initialValue: '', validator: (val) => items.contains(val) ? 'Already exists.' : null, ), ); if (newItem != null) { setState(() => items.add(newItem)); } }, ), ], ), ), ListView.builder( shrinkWrap: true, controller: widget.scrollCtrl, itemCount: items.length, itemBuilder: (context, i) => ListTile( key: Key(items[i]), title: Text(items[i]), trailing: Row( mainAxisSize: .min, children: [ IconButton( tooltip: 'Remove', icon: const Icon(Icons.delete_rounded), onPressed: () => setState(() => items.removeAt(i)), ), IconButton( tooltip: 'Rename', icon: const Icon(Icons.edit_rounded), onPressed: () async { final renamedItem = await showDialog( context: context, builder: (context) => TextInputDialog( title: widget.label, initialValue: items[i], validator: (val) => items.contains(val) && val != items[i] ? 'Already exists.' : null, ), ); if (renamedItem != null) { setState(() => items[i] = renamedItem); } }, ), ], ), ), ), ], ); } } ================================================ FILE: lib/feature/settings/settings_model.dart ================================================ import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/notification/notifications_model.dart'; /// Some fields are modifiable to allow for quick and simple edits. /// But to apply those edits, the [SettingsNotifier] should be used. class Settings { Settings._({ required this.unreadNotifications, required this.scoreFormat, required this.defaultSort, required this.titleLanguage, required this.personNaming, required this.activityMergeTime, required this.splitCompletedAnime, required this.splitCompletedManga, required this.displayAdultContent, required this.airingNotifications, required this.advancedScoringEnabled, required this.restrictMessagesToFollowing, required this.advancedScoreSections, required this.animeCustomLists, required this.mangaCustomLists, required this.disabledListActivity, required this.notificationOptions, }); factory Settings(Map map) => Settings._( unreadNotifications: map['unreadNotificationCount'] ?? 0, scoreFormat: ScoreFormat.from(map['mediaListOptions']?['scoreFormat']), defaultSort: EntrySort.fromRowOrder(map['mediaListOptions']?['rowOrder']), titleLanguage: TitleLanguage.from(map['options']?['titleLanguage']), personNaming: PersonNaming.from(map['options']?['staffNameLanguage']), activityMergeTime: map['options']?['activityMergeTime'] ?? 720, splitCompletedAnime: map['mediaListOptions']?['animeList']?['splitCompletedSectionByFormat'] ?? false, splitCompletedManga: map['mediaListOptions']?['mangaList']?['splitCompletedSectionByFormat'] ?? false, displayAdultContent: map['options']?['displayAdultContent'] ?? false, airingNotifications: map['options']?['airingNotifications'] ?? true, advancedScoringEnabled: map['mediaListOptions']?['animeList']?['advancedScoringEnabled'] ?? false, restrictMessagesToFollowing: map['options']?['restrictMessagesToFollowing'] ?? false, advancedScoreSections: List.from( map['mediaListOptions']?['animeList']?['advancedScoring'] ?? const [], ), animeCustomLists: List.from( map['mediaListOptions']?['animeList']?['customLists'] ?? const [], ), mangaCustomLists: List.from( map['mediaListOptions']?['mangaList']?['customLists'] ?? const [], ), disabledListActivity: { for (var activity in map['options']?['disabledListActivity'] ?? const []) ?ListStatus.from(activity['type']): activity['disabled'], }, notificationOptions: { for (var option in map['options']?['notificationOptions'] ?? const []) ?NotificationType.from(option['type']): option['enabled'], }, ); factory Settings.empty() => Settings._( unreadNotifications: 0, scoreFormat: .point10, defaultSort: .title, titleLanguage: .romaji, personNaming: .romajiWestern, activityMergeTime: 720, splitCompletedAnime: false, splitCompletedManga: false, displayAdultContent: false, airingNotifications: true, advancedScoringEnabled: false, restrictMessagesToFollowing: false, advancedScoreSections: const [], animeCustomLists: const [], mangaCustomLists: const [], disabledListActivity: const {}, notificationOptions: const {}, ); ScoreFormat scoreFormat; EntrySort defaultSort; TitleLanguage titleLanguage; PersonNaming personNaming; int activityMergeTime; bool splitCompletedAnime; bool splitCompletedManga; bool displayAdultContent; bool airingNotifications; bool advancedScoringEnabled; bool restrictMessagesToFollowing; final int unreadNotifications; final List advancedScoreSections; final List animeCustomLists; final List mangaCustomLists; final Map disabledListActivity; final Map notificationOptions; Settings copy({int unreadNotifications = 0}) => Settings._( unreadNotifications: unreadNotifications, scoreFormat: scoreFormat, defaultSort: defaultSort, titleLanguage: titleLanguage, personNaming: personNaming, activityMergeTime: activityMergeTime, splitCompletedAnime: splitCompletedAnime, splitCompletedManga: splitCompletedManga, displayAdultContent: displayAdultContent, airingNotifications: airingNotifications, advancedScoringEnabled: advancedScoringEnabled, restrictMessagesToFollowing: restrictMessagesToFollowing, advancedScoreSections: [...advancedScoreSections], animeCustomLists: [...animeCustomLists], mangaCustomLists: [...mangaCustomLists], disabledListActivity: {...disabledListActivity}, notificationOptions: {...notificationOptions}, ); Map toGraphQlVariables() => { 'titleLanguage': titleLanguage.value, 'staffNameLanguage': personNaming.value, 'activityMergeTime': activityMergeTime, 'displayAdultContent': displayAdultContent, 'scoreFormat': scoreFormat.value, 'rowOrder': defaultSort.toRowOrder(), 'advancedScoring': advancedScoreSections, 'advancedScoringEnabled': advancedScoringEnabled, 'animeCustomLists': animeCustomLists, 'mangaCustomLists': mangaCustomLists, 'splitCompletedAnime': splitCompletedAnime, 'splitCompletedManga': splitCompletedManga, 'restrictMessagesToFollowing': restrictMessagesToFollowing, 'airingNotifications': airingNotifications, 'disabledListActivity': disabledListActivity.entries .map((e) => {'type': e.key.value, 'disabled': e.value}) .toList(), 'notificationOptions': notificationOptions.entries .map((e) => {'type': e.key.value, 'enabled': e.value}) .toList(), }; } enum TitleLanguage { romaji('Romaji', 'ROMAJI'), english('English', 'ENGLISH'), native('Native', 'NATIVE'); const TitleLanguage(this.label, this.value); final String label; final String value; static TitleLanguage from(String? value) => TitleLanguage.values.firstWhere((v) => v.value == value, orElse: () => romaji); } enum PersonNaming { romajiWestern('Romaji, Western Order', 'ROMAJI_WESTERN'), romaji('Romaji', 'ROMAJI'), native('Native', 'NATIVE'); const PersonNaming(this.label, this.value); final String label; final String value; static PersonNaming from(String? value) => PersonNaming.values.firstWhere((v) => v.value == value, orElse: () => romajiWestern); } ================================================ FILE: lib/feature/settings/settings_notifications_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/input/stateful_tiles.dart'; import 'package:otraku/feature/settings/settings_model.dart'; class SettingsNotificationsSubview extends StatelessWidget { const SettingsNotificationsSubview(this.scrollCtrl, this.settings); final ScrollController scrollCtrl; final Settings settings; @override Widget build(BuildContext context) { final listPadding = MediaQuery.paddingOf(context); return ListView.builder( controller: scrollCtrl, padding: .only( top: listPadding.top + Theming.offset, bottom: listPadding.bottom + Theming.offset, ), itemCount: settings.notificationOptions.length, itemBuilder: (context, i) { final e = settings.notificationOptions.entries.elementAt(i); return StatefulCheckboxListTile( title: Text(e.key.label), value: e.value, onChanged: (v) => settings.notificationOptions[e.key] = v!, ); }, ); } } ================================================ FILE: lib/feature/settings/settings_provider.dart ================================================ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; import 'package:otraku/feature/collection/collection_provider.dart'; import 'package:otraku/feature/settings/settings_model.dart'; final settingsProvider = AsyncNotifierProvider.autoDispose( SettingsNotifier.new, ); class SettingsNotifier extends AsyncNotifier { @override FutureOr build() async { final viewerId = ref.watch(viewerIdProvider); if (viewerId == null) return .empty(); final data = await ref.read(repositoryProvider).request(GqlQuery.settings); return Settings(data['Viewer']); } /// Update settings and if necessary /// restart collections to reflect the changes. Future updateSettings(Settings other) async { final viewerId = ref.watch(viewerIdProvider); if (viewerId == null) return; final prev = state.value; state = await AsyncValue.guard(() async { final data = await ref .read(repositoryProvider) .request(GqlMutation.updateSettings, other.toGraphQlVariables()); return Settings(data['UpdateUser']); }); final next = state.value; if (prev == null || next == null) return; var invalidateAnimeCollection = false; var invalidateMangaCollection = false; if (prev.scoreFormat != next.scoreFormat || prev.titleLanguage != next.titleLanguage) { invalidateAnimeCollection = true; invalidateMangaCollection = true; } else { if (prev.splitCompletedAnime != next.splitCompletedAnime || !listEquals(prev.animeCustomLists, next.animeCustomLists)) { invalidateAnimeCollection = true; } if (prev.splitCompletedManga != next.splitCompletedManga || !listEquals(prev.mangaCustomLists, next.mangaCustomLists)) { invalidateMangaCollection = true; } } if (invalidateAnimeCollection) { ref.invalidate(collectionProvider((userId: viewerId, ofAnime: true))); } if (invalidateMangaCollection) { ref.invalidate(collectionProvider((userId: viewerId, ofAnime: false))); } } Future refetchUnread() async { try { final data = await ref.read(repositoryProvider).request(GqlQuery.settings, { 'withData': false, }); state = state.whenData( (v) => v.copy(unreadNotifications: data['Viewer']['unreadNotificationCount'] ?? 0), ); } catch (_) {} } void clearUnread() => state = state.whenData((v) => v.copy(unreadNotifications: 0)); } ================================================ FILE: lib/feature/settings/settings_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/scroll_controller_extension.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/settings/settings_model.dart'; import 'package:otraku/feature/settings/settings_provider.dart'; import 'package:otraku/feature/settings/settings_app_view.dart'; import 'package:otraku/feature/settings/settings_content_view.dart'; import 'package:otraku/feature/settings/settings_notifications_view.dart'; import 'package:otraku/feature/settings/settings_about_view.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/loaders.dart'; class SettingsView extends ConsumerStatefulWidget { const SettingsView(); @override ConsumerState createState() => _SettingsViewState(); } class _SettingsViewState extends ConsumerState with SingleTickerProviderStateMixin { late final _tabCtrl = TabController(length: 4, vsync: this); final _scrollCtrl = ScrollController(); AsyncValue? _settings; @override void initState() { super.initState(); _tabCtrl.addListener(() => setState(() {})); } @override void dispose() { _tabCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final viewerId = ref.watch(viewerIdProvider); if (viewerId == null) { _settings = null; } else { _settings ??= ref.watch(settingsProvider).whenData((data) => data.copy()); ref.listen( settingsProvider, (_, s) => s.whenOrNull( loading: () => _settings = const AsyncValue.loading(), data: (data) => _settings = AsyncValue.data(data.copy()), error: (error, _) => SnackBarExtension.show(context, error.toString()), ), ); } final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast)); final tabs = [ ConstrainedView(padded: false, child: SettingsAppSubview(_scrollCtrl)), switch (_settings) { null => const Center( child: Padding( padding: Theming.paddingAll, child: Text('Log in to view content settings'), ), ), AsyncData(:final value) => SettingsContentSubview(_scrollCtrl, value, highContrast), AsyncError(:final error) => Center( child: Padding( padding: Theming.paddingAll, child: Text('Failed to load: ${error.toString()}'), ), ), AsyncLoading() => const Center(child: Loader()), }, switch (_settings) { null => const Center( child: Padding( padding: Theming.paddingAll, child: Text('Log in to view notification settings'), ), ), AsyncData(:final value) => SettingsNotificationsSubview(_scrollCtrl, value), AsyncError(:final error) => Center( child: Padding( padding: Theming.paddingAll, child: Text('Failed to load: ${error.toString()}'), ), ), AsyncLoading() => const Center(child: Loader()), }, ConstrainedView(padded: false, child: SettingsAboutSubview(_scrollCtrl)), ]; final floatingAction = switch (_settings) { AsyncData(:final value) => HidingFloatingActionButton( key: const Key('save'), scrollCtrl: _scrollCtrl, child: _SaveButton(() => ref.read(settingsProvider.notifier).updateSettings(value)), ), _ => null, }; return AdaptiveScaffold( topBar: TopBarAnimatedSwitcher(switch (_tabCtrl.index) { 0 => const TopBar(key: Key('0'), title: 'App'), 1 => const TopBar(key: Key('1'), title: 'Content'), 2 => const TopBar(key: Key('2'), title: 'Notifications'), _ => const TopBar(key: Key('3'), title: 'About'), }), floatingAction: floatingAction, navigationConfig: NavigationConfig( selected: _tabCtrl.index, onSame: (_) => _scrollCtrl.scrollToTop(), onChanged: (i) => _tabCtrl.index = i, items: const { 'App': Ionicons.color_palette_outline, 'Content': Ionicons.tv_outline, 'Notifications': Ionicons.notifications_outline, 'About': Ionicons.information_outline, }, ), child: TabBarView(controller: _tabCtrl, children: tabs), ); } } class _SaveButton extends StatefulWidget { const _SaveButton(this.onTap) : super(key: const Key('saveSettings')); final Future Function() onTap; @override State<_SaveButton> createState() => __SaveButtonState(); } class __SaveButtonState extends State<_SaveButton> { var _hidden = false; @override Widget build(BuildContext context) { return FloatingActionButton( tooltip: 'Save Settings', onPressed: _hidden ? null : () async { setState(() => _hidden = true); await widget.onTap(); setState(() => _hidden = false); }, child: _hidden ? const Icon(Ionicons.time_outline) : const Icon(Ionicons.save_outline), ); } } ================================================ FILE: lib/feature/settings/theme_preview.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/widget/shadowed_overflow_list.dart'; import 'package:otraku/util/theming.dart'; const _previewHeight = 170.0; class ThemePreview extends StatelessWidget { const ThemePreview({required this.ref, required this.options}); final WidgetRef ref; final Options options; @override Widget build(BuildContext context) { final brightness = ColorScheme.of(context).brightness; final systemPrimaryColor = ref.watch( persistenceProvider.select( (s) => brightness == Brightness.dark ? s.systemColors.darkPrimaryColor : s.systemColors.lightPrimaryColor, ), ); final background = options.highContrast ? brightness == Brightness.dark ? Colors.black : Colors.white : null; final bodyMediumLineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final children = <_ThemeCard>[]; if (systemPrimaryColor != null) { children.add( _ThemeCard( name: 'System', scheme: ColorScheme.fromSeed( seedColor: systemPrimaryColor, brightness: brightness, ).copyWith(surface: background), active: options.themeBase == null, onTap: () => ref .read(persistenceProvider.notifier) .setOptions(options.copyWith(themeBase: (null,))), ), ); } for (final tb in ThemeBase.values) { children.add( _ThemeCard( name: tb.title, scheme: ColorScheme.fromSeed( seedColor: tb.seed, brightness: brightness, ).copyWith(surface: background), active: options.themeBase == tb, onTap: () => ref.read(persistenceProvider.notifier).setOptions(options.copyWith(themeBase: (tb,))), ), ); } return SizedBox( height: _previewHeight + bodyMediumLineHeight + 5, child: ShadowedOverflowList( itemCount: children.length, itemExtent: 125, itemBuilder: (_, i) => children[i], ), ); } } class _ThemeCard extends StatelessWidget { const _ThemeCard({ required this.name, required this.active, required this.scheme, required this.onTap, }); final String name; final bool active; final ColorScheme scheme; final void Function() onTap; @override Widget build(BuildContext context) { final borderWidth = active ? 3.0 : 1.0; final borderColor = active ? scheme.primary : scheme.surfaceContainerHighest; return GestureDetector( onTap: onTap, child: Column( spacing: 5, children: [ Container( height: _previewHeight, padding: const .all(5), decoration: BoxDecoration( color: scheme.surface, border: .all(color: borderColor, width: borderWidth), borderRadius: Theming.borderRadiusSmall, ), child: Column( children: [ Column( crossAxisAlignment: .start, children: [ Container( height: Theming.offset, width: 60, decoration: BoxDecoration( color: scheme.onSurface, borderRadius: Theming.borderRadiusBig, ), ), const SizedBox(height: Theming.offset), Container( height: 40, padding: const .all(5), decoration: BoxDecoration( color: scheme.surfaceContainerHighest, borderRadius: Theming.borderRadiusSmall, ), child: Column( crossAxisAlignment: .start, children: [ Container( height: 8, width: 40, decoration: BoxDecoration( color: scheme.surfaceContainerHighest, borderRadius: Theming.borderRadiusBig, ), ), const SizedBox(height: 5), Container( height: 6, width: 110, decoration: BoxDecoration( color: scheme.onSurfaceVariant, borderRadius: Theming.borderRadiusBig, ), ), ], ), ), ], ), const Spacer(), Row( mainAxisAlignment: .end, children: [ Container( width: 16, height: 16, margin: const .only(right: 7, bottom: 7), decoration: BoxDecoration(shape: .circle, color: scheme.primary), child: Center( child: Container( width: 6, height: 2, decoration: BoxDecoration( shape: .rectangle, borderRadius: Theming.borderRadiusSmall, color: scheme.onPrimary, ), ), ), ), ], ), SizedBox( height: 15, child: Row( mainAxisAlignment: .spaceEvenly, children: [ Container( height: 8, width: 8, decoration: BoxDecoration(color: scheme.primary, shape: .rectangle), ), Container( height: 8, width: 8, decoration: BoxDecoration( color: scheme.surfaceContainerHighest, shape: .circle, ), ), Container( height: 8, width: 8, decoration: BoxDecoration( color: scheme.surfaceContainerHighest, shape: .circle, ), ), ], ), ), ], ), ), Text(name, overflow: .ellipsis, maxLines: 1), ], ), ); } } ================================================ FILE: lib/feature/social/social_model.dart ================================================ import 'package:otraku/feature/comment/comment_model.dart'; import 'package:otraku/feature/forum/forum_model.dart'; import 'package:otraku/feature/user/user_item_model.dart'; import 'package:otraku/util/paged.dart'; class Social { const Social({ this.following = const PagedWithTotal(), this.followers = const PagedWithTotal(), this.threads = const PagedWithTotal(), this.comments = const PagedWithTotal(), }); final PagedWithTotal following; final PagedWithTotal followers; final PagedWithTotal threads; final PagedWithTotal comments; int getCount(SocialTab tab) => switch (tab) { .following => following.total, .followers => followers.total, .threads => threads.total, .comments => comments.total, }; } enum SocialTab { following, followers, threads, comments; String get title => switch (this) { .following => 'Following', .followers => 'Followers', .threads => 'Threads', .comments => 'Comments', }; } ================================================ FILE: lib/feature/social/social_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/comment/comment_model.dart'; import 'package:otraku/feature/forum/forum_model.dart'; import 'package:otraku/feature/social/social_model.dart'; import 'package:otraku/feature/user/user_item_model.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final socialProvider = AsyncNotifierProvider.autoDispose.family( SocialNotifier.new, ); class SocialNotifier extends AsyncNotifier { SocialNotifier(this.arg); final int arg; @override FutureOr build() => _fetch(const Social(), null); Future fetch(SocialTab tab) async { final oldState = state.value ?? const Social(); switch (tab) { case .following: if (!oldState.following.hasNext) return; case .followers: if (!oldState.followers.hasNext) return; case .threads: if (!oldState.threads.hasNext) return; case .comments: if (!oldState.comments.hasNext) return; } state = await AsyncValue.guard(() => _fetch(oldState, tab)); } Future _fetch(Social oldState, SocialTab? tab) async { final variables = {'userId': arg}; switch (tab) { case null: variables['withFollowing'] = true; variables['withFollowers'] = true; variables['withThreads'] = true; variables['withComments'] = true; break; case .following: variables['withFollowing'] = true; variables['page'] = oldState.following.next; break; case .followers: variables['withFollowers'] = true; variables['page'] = oldState.followers.next; break; case .threads: variables['withThreads'] = true; variables['page'] = oldState.threads.next; break; case .comments: variables['withComments'] = true; variables['page'] = oldState.comments.next; break; } final data = await ref.read(repositoryProvider).request(GqlQuery.social, variables); var following = oldState.following; var followers = oldState.followers; var threads = oldState.threads; var comments = oldState.comments; if (tab == null || tab == .following) { final map = data['following']; final items = []; for (final u in map['following']) { items.add(UserItem(u)); } following = following.withNext( items, map['pageInfo']['hasNextPage'] ?? false, map['pageInfo']['total'], ); } if (tab == null || tab == .followers) { final map = data['followers']; final items = []; for (final u in map['followers']) { items.add(UserItem(u)); } followers = followers.withNext( items, map['pageInfo']['hasNextPage'] ?? false, map['pageInfo']['total'], ); } if (tab == null || tab == .threads) { final map = data['threads']; final items = []; for (final u in map['threads']) { items.add(ThreadItem(u)); } threads = threads.withNext( items, map['pageInfo']['hasNextPage'] ?? false, map['pageInfo']['total'], ); } if (tab == null || tab == .comments) { final map = data['comments']; final items = []; for (final u in map['threadComments']) { items.add(Comment(u)); } comments = comments.withNext( items, map['pageInfo']['hasNextPage'] ?? false, map['pageInfo']['total'], ); } return Social(following: following, followers: followers, threads: threads, comments: comments); } } ================================================ FILE: lib/feature/social/social_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/scroll_controller_extension.dart'; import 'package:otraku/feature/comment/comment_model.dart'; import 'package:otraku/feature/comment/comment_tile.dart'; import 'package:otraku/feature/forum/thread_item_list.dart'; import 'package:otraku/feature/social/social_model.dart'; import 'package:otraku/feature/social/social_provider.dart'; import 'package:otraku/feature/user/user_item_grid.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/paged_view.dart'; class SocialView extends ConsumerStatefulWidget { const SocialView(this.id); final int id; @override ConsumerState createState() => _SocialViewState(); } class _SocialViewState extends ConsumerState with SingleTickerProviderStateMixin { late final _tabCtrl = TabController(length: SocialTab.values.length, vsync: this); late final _scrollCtrl = PagedController( loadMore: () => ref.read(socialProvider(widget.id).notifier).fetch(SocialTab.values[_tabCtrl.index]), ); @override void initState() { super.initState(); _tabCtrl.addListener(() => setState(() {})); } @override void dispose() { _tabCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final tab = SocialTab.values[_tabCtrl.index]; final viewerId = ref.watch(viewerIdProvider); final options = ref.watch(persistenceProvider.select((s) => s.options)); final count = ref.watch(socialProvider(widget.id).select((s) => s.value?.getCount(tab) ?? 0)); final onRefresh = (invalidate) => invalidate(socialProvider(widget.id)); return AdaptiveScaffold( topBar: TopBarAnimatedSwitcher( TopBar( key: Key('${tab.title}TopBar'), title: tab.title, trailing: [ if (count > 0) Padding( padding: const .only(right: Theming.offset), child: Text(count.toString(), style: TextTheme.of(context).titleSmall), ), ], ), ), navigationConfig: NavigationConfig( selected: _tabCtrl.index, onChanged: (i) => _tabCtrl.index = i, onSame: (_) => _scrollCtrl.scrollToTop(), items: { SocialTab.following.title: Ionicons.people_circle, SocialTab.followers.title: Ionicons.person_circle, SocialTab.threads.title: Ionicons.chatbubble_outline, SocialTab.comments.title: Ionicons.chatbubbles_outline, }, ), child: TabBarView( controller: _tabCtrl, children: [ PagedView( scrollCtrl: _scrollCtrl, onRefresh: onRefresh, provider: socialProvider( widget.id, ).select((s) => s.unwrapPrevious().whenData((data) => data.following)), onData: (data) => UserItemGrid(data.items, highContrast: options.highContrast), ), PagedView( scrollCtrl: _scrollCtrl, onRefresh: onRefresh, provider: socialProvider( widget.id, ).select((s) => s.unwrapPrevious().whenData((data) => data.followers)), onData: (data) => UserItemGrid(data.items, highContrast: options.highContrast), ), PagedView( scrollCtrl: _scrollCtrl, onRefresh: onRefresh, provider: socialProvider( widget.id, ).select((s) => s.unwrapPrevious().whenData((data) => data.threads)), onData: (data) => ThreadItemList(data.items, options.highContrast, options.analogClock), ), PagedView( scrollCtrl: _scrollCtrl, onRefresh: onRefresh, provider: socialProvider( widget.id, ).select((s) => s.unwrapPrevious().whenData((data) => data.comments)), onData: (data) => _CommentItemList(data.items, viewerId, options.highContrast, options.analogClock), ), ], ), ); } } class _CommentItemList extends StatelessWidget { const _CommentItemList(this.items, this.viewerId, this.highContrast, this.analogClock); final List items; final int? viewerId; final bool highContrast; final bool analogClock; @override Widget build(BuildContext context) { return SliverList.builder( itemCount: items.length, itemBuilder: (context, i) { final item = items[i]; final openThread = () => context.push(Routes.thread(item.threadId)); return Padding( padding: const .only(bottom: Theming.offset), child: Column( crossAxisAlignment: .start, spacing: Theming.offset, children: [ Semantics( onTap: openThread, onTapHint: 'open thread', child: GestureDetector( onTap: openThread, behavior: .opaque, child: Text(item.threadTitle, style: TextTheme.of(context).bodyMedium), ), ), CommentTile( item, viewerId: viewerId, highContrast: highContrast, analogClock: analogClock, ), ], ), ); }, ); } } ================================================ FILE: lib/feature/staff/staff_characters_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/staff/staff_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/widget/grid/dual_relation_grid.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/feature/staff/staff_provider.dart'; class StaffCharactersSubview extends StatelessWidget { const StaffCharactersSubview({ required this.id, required this.scrollCtrl, required this.highContrast, }); final int id; final ScrollController scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { return PagedView<(StaffRelatedItem, StaffRelatedItem)>( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(staffRelationsProvider(id)), provider: staffRelationsProvider( id, ).select((s) => s.unwrapPrevious().whenData((data) => data.charactersAndMedia)), onData: (data) => DualRelationGrid( items: data.items, onTapPrimary: (item) => context.push(Routes.character(item.tileId, item.tileImageUrl)), onTapSecondary: (item) => context.push(Routes.media(item.tileId, item.tileImageUrl)), highContrast: highContrast, ), ); } } ================================================ FILE: lib/feature/staff/staff_filter_model.dart ================================================ import 'package:otraku/feature/media/media_models.dart'; class StaffFilter { const StaffFilter({this.sort = .startDateDesc, this.ofAnime, this.inLists}); final MediaSort sort; final bool? ofAnime; final bool? inLists; StaffFilter copyWith({MediaSort? sort, (bool?,)? ofAnime, (bool?,)? inLists}) => StaffFilter( sort: sort ?? this.sort, ofAnime: ofAnime == null ? this.ofAnime : ofAnime.$1, inLists: inLists == null ? this.inLists : inLists.$1, ); } ================================================ FILE: lib/feature/staff/staff_filter_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/staff/staff_filter_model.dart'; final staffFilterProvider = NotifierProvider.autoDispose .family(StaffFilterNotifier.new); class StaffFilterNotifier extends Notifier { StaffFilterNotifier(this.arg); final int arg; @override StaffFilter build() => const StaffFilter(); @override set state(StaffFilter newState) => super.state = newState; } ================================================ FILE: lib/feature/staff/staff_floating_actions.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/staff/staff_filter_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/sheets.dart'; class StaffFilterButton extends StatelessWidget { const StaffFilterButton(this.id, this.ref) : super(key: const Key('filterStaff')); final int id; final WidgetRef ref; @override Widget build(BuildContext context) { return FloatingActionButton( tooltip: 'Filter', heroTag: 'filter', child: const Icon(Ionicons.funnel_outline), onPressed: () { var filter = ref.read(staffFilterProvider(id)); final onDone = (_) => ref.read(staffFilterProvider(id).notifier).state = filter; final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast)); showSheet( context, SimpleSheet( initialHeight: Theming.normalTapTarget * 4 + MediaQuery.paddingOf(context).bottom + 40, builder: (context, scrollCtrl) => ListView( controller: scrollCtrl, physics: Theming.bouncyPhysics, padding: const .symmetric(horizontal: Theming.offset, vertical: 20), children: [ ChipSelector.ensureSelected( title: 'Sort', items: MediaSort.values.map((v) => (v.label, v)).toList(), value: filter.sort, onChanged: (v) => filter = filter.copyWith(sort: v), highContrast: highContrast, ), ChipSelector( title: 'Type', items: const [('Anime', true), ('Manga', false)], value: filter.ofAnime, onChanged: (v) => filter = filter.copyWith(ofAnime: (v,)), highContrast: highContrast, ), const SizedBox(height: Theming.offset), ChipSelector( title: 'List Presence', items: const [('In Lists', true), ('Not in Lists', false)], value: filter.inLists, onChanged: (v) => filter = filter.copyWith(inLists: (v,)), highContrast: highContrast, ), ], ), ), ).then(onDone); }, ); } } ================================================ FILE: lib/feature/staff/staff_header.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/staff/staff_model.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/content_header.dart'; import 'package:otraku/widget/table_list.dart'; class StaffHeader extends StatelessWidget { const StaffHeader.withTabBar({ required this.id, required this.imageUrl, required this.staff, required this.tabCtrl, required this.scrollToTop, required this.toggleFavorite, required this.highContrast, }); const StaffHeader.withoutTabBar({ required this.id, required this.imageUrl, required this.staff, required this.toggleFavorite, required this.highContrast, }) : tabCtrl = null, scrollToTop = null; final int id; final String? imageUrl; final Staff? staff; final TabController? tabCtrl; final void Function()? scrollToTop; final Future Function() toggleFavorite; final bool highContrast; @override Widget build(BuildContext context) { return ContentHeader( imageUrl: imageUrl ?? staff?.imageUrl, imageHeightToWidthRatio: Theming.coverHtoWRatio, imageHeroTag: id, siteUrl: staff?.siteUrl, title: staff?.preferredName, details: staff != null ? [ TableList([ ('Favorites', staff!.favorites.toString()), if (staff!.gender != null) ('Gender', staff!.gender!), ], highContrast: highContrast), ] : const [], tabBarConfig: tabCtrl != null && scrollToTop != null ? (tabCtrl: tabCtrl!, scrollToTop: scrollToTop!, tabs: tabsWithOverview) : null, trailingTopButtons: [if (staff != null) _FavoriteButton(staff!, toggleFavorite)], ); } static const tabsWithoutOverview = [Tab(text: 'Characters'), Tab(text: 'Roles')]; static const tabsWithOverview = [Tab(text: 'Overview'), ...tabsWithoutOverview]; } class _FavoriteButton extends StatefulWidget { const _FavoriteButton(this.staff, this.toggleFavorite); final Staff staff; final Future Function() toggleFavorite; @override State<_FavoriteButton> createState() => __FavoriteButtonState(); } class __FavoriteButtonState extends State<_FavoriteButton> { @override Widget build(BuildContext context) { final staff = widget.staff; return IconButton( tooltip: staff.isFavorite ? 'Unfavourite' : 'Favourite', icon: staff.isFavorite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border), onPressed: () async { setState(() => staff.isFavorite = !staff.isFavorite); final err = await widget.toggleFavorite(); if (err == null) return; setState(() => staff.isFavorite = !staff.isFavorite); if (context.mounted) SnackBarExtension.show(context, err.toString()); }, ); } } ================================================ FILE: lib/feature/staff/staff_item_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/staff/staff_item_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; class StaffItemGrid extends StatelessWidget { const StaffItemGrid(this.items, {required this.highContrast}); final List items; final bool highContrast; @override Widget build(BuildContext context) { final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final textHeight = lineHeight * 2 + 10; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight( minWidth: 100, extraHeight: textHeight, rawHWRatio: Theming.coverHtoWRatio, ), delegate: SliverChildBuilderDelegate( (_, i) => _Tile(items[i], highContrast, textHeight), childCount: items.length, ), ); } } class _Tile extends StatelessWidget { const _Tile(this.item, this.highContrast, this.textHeight); final StaffItem item; final bool highContrast; final double textHeight; @override Widget build(BuildContext context) { return InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => context.push(Routes.staff(item.id, item.imageUrl)), child: CardExtension.highContrast(highContrast)( child: Column( crossAxisAlignment: .stretch, children: [ Expanded( child: Hero( tag: item.id, child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall), child: CachedImage(item.imageUrl), ), ), ), SizedBox( height: textHeight, child: Padding( padding: const .all(5), child: Text(item.name, maxLines: 2, overflow: .ellipsis), ), ), ], ), ), ); } } ================================================ FILE: lib/feature/staff/staff_item_model.dart ================================================ class StaffItem { const StaffItem._({required this.id, required this.name, required this.imageUrl}); factory StaffItem(Map map) => StaffItem._( id: map['id'], name: map['name']['userPreferred'], imageUrl: map['image']['large'], ); final int id; final String name; final String imageUrl; } ================================================ FILE: lib/feature/staff/staff_model.dart ================================================ import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/util/markdown.dart'; import 'package:otraku/feature/settings/settings_model.dart'; import 'package:otraku/util/tile_modelable.dart'; class Staff { Staff._({ required this.id, required this.preferredName, required this.fullName, required this.nativeName, required this.altNames, required this.imageUrl, required this.description, required this.dateOfBirth, required this.dateOfDeath, required this.bloodType, required this.homeTown, required this.gender, required this.age, required this.startYear, required this.endYear, required this.siteUrl, required this.favorites, required this.isFavorite, }); factory Staff(Map map, PersonNaming personNaming) { final names = map['name']; final nameSegments = [ names['first'], if (names['middle']?.isNotEmpty ?? false) names['middle'], if (names['last']?.isNotEmpty ?? false) names['last'], ]; final fullName = personNaming == .romajiWestern ? nameSegments.join(' ') : nameSegments.reversed.toList().join(' '); final nativeName = names['native']; final altNames = List.from(names['alternative'] ?? []); final preferredName = nativeName != null ? personNaming != .native ? fullName : nativeName : fullName; final yearsActive = map['yearsActive'] as List?; return Staff._( id: map['id'], preferredName: preferredName, fullName: fullName, nativeName: nativeName, altNames: altNames, imageUrl: map['image']['large'], description: parseMarkdown(map['description'] ?? ''), dateOfBirth: StringExtension.fromFuzzyDate(map['dateOfBirth']), dateOfDeath: StringExtension.fromFuzzyDate(map['dateOfDeath']), bloodType: map['bloodType'], homeTown: map['homeTown'], gender: map['gender'], age: map['age']?.toString(), startYear: yearsActive != null && yearsActive.isNotEmpty ? yearsActive[0].toString() : null, endYear: yearsActive != null && yearsActive.length > 1 ? yearsActive[1].toString() : null, siteUrl: map['siteUrl'], favorites: map['favourites'] ?? 0, isFavorite: map['isFavourite'] ?? false, ); } final int id; final String preferredName; final String fullName; final String? nativeName; final List altNames; final String imageUrl; final String description; final String? dateOfBirth; final String? dateOfDeath; final String? bloodType; final String? homeTown; final String? gender; final String? age; final String? startYear; final String? endYear; final String? siteUrl; final int favorites; bool isFavorite; } class StaffRelations { const StaffRelations({this.charactersAndMedia = const Paged(), this.roles = const Paged()}); final Paged<(StaffRelatedItem, StaffRelatedItem)> charactersAndMedia; final Paged roles; } class StaffRelatedItem implements TileModelable { const StaffRelatedItem._({ required this.id, required this.name, required this.imageUrl, required this.role, }); factory StaffRelatedItem.media( Map map, String? role, ImageQuality imageQuality, ) => StaffRelatedItem._( id: map['id'], name: map['title']['userPreferred'], imageUrl: map['coverImage'][imageQuality.value], role: role, ); factory StaffRelatedItem.character(Map map, String? role) => StaffRelatedItem._( id: map['id'], name: map['name']['userPreferred'], imageUrl: map['image']['large'], role: role, ); final int id; final String name; final String imageUrl; final String? role; @override int get tileId => id; @override String get tileTitle => name; @override String? get tileSubtitle => role; @override String get tileImageUrl => imageUrl; } ================================================ FILE: lib/feature/staff/staff_overview_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:otraku/feature/staff/staff_model.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/table_list.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:otraku/widget/loaders.dart'; class StaffOverviewSubview extends StatelessWidget { const StaffOverviewSubview.asFragment({ required this.staff, required this.invalidate, required this.highContrast, required ScrollController this.scrollCtrl, }) : header = null; const StaffOverviewSubview.withHeader({ required this.staff, required this.invalidate, required this.highContrast, required Widget this.header, }) : scrollCtrl = null; final Staff staff; final void Function() invalidate; final Widget? header; final ScrollController? scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); final refreshControl = SliverRefreshControl(onRefresh: invalidate); return CustomScrollView( physics: Theming.bouncyPhysics, controller: scrollCtrl, slivers: [ if (header != null) ...[ header!, MediaQuery( data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)), child: refreshControl, ), ] else refreshControl, SliverPadding( padding: const .symmetric(horizontal: Theming.offset), sliver: SliverMainAxisGroup( slivers: [ SliverTableList([ ('Full', staff.fullName), if (staff.nativeName != null) ('Native', staff.nativeName!), ...staff.altNames.map((s) => ('Alternative', s)), ], highContrast: highContrast), const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)), SliverTableList([ if (staff.dateOfBirth != null) ('Birth', staff.dateOfBirth!), if (staff.dateOfDeath != null) ('Death', staff.dateOfDeath!), if (staff.age != null) ('Age', staff.age!), if (staff.startYear != null) ('Years Active', '${staff.startYear} - ${staff.endYear ?? 'Present'}'), if (staff.homeTown != null) ('Home Town', staff.homeTown!), if (staff.bloodType != null) ('Blood Type', staff.bloodType!), ], highContrast: highContrast), if (staff.description.isNotEmpty) ...[ const SliverToBoxAdapter(child: SizedBox(height: 15)), HtmlContent(staff.description, renderMode: RenderMode.sliverList), ], ], ), ), const SliverFooter(), ], ); } } ================================================ FILE: lib/feature/staff/staff_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/feature/staff/staff_filter_model.dart'; import 'package:otraku/feature/settings/settings_provider.dart'; import 'package:otraku/feature/staff/staff_filter_provider.dart'; import 'package:otraku/feature/staff/staff_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final staffProvider = AsyncNotifierProvider.autoDispose.family( StaffNotifier.new, ); final staffRelationsProvider = AsyncNotifierProvider.autoDispose .family(StaffRelationsNotifier.new); class StaffNotifier extends AsyncNotifier { StaffNotifier(this.arg); final int arg; @override FutureOr build() async { final data = await ref.read(repositoryProvider).request(GqlQuery.staff, { 'id': arg, 'withInfo': true, }); final personNaming = await ref.watch( settingsProvider.selectAsync((settings) => settings.personNaming), ); return Staff(data['Staff'], personNaming); } Future toggleFavorite() { return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, { 'staff': arg, }).getErrorOrNull(); } } class StaffRelationsNotifier extends AsyncNotifier { StaffRelationsNotifier(this.arg); final int arg; late StaffFilter filter; @override FutureOr build() async { filter = ref.watch(staffFilterProvider(arg)); return await _fetch(const StaffRelations(), null); } Future fetch(bool onCharacters) async { final oldState = state.value ?? const StaffRelations(); if (onCharacters) { if (!oldState.charactersAndMedia.hasNext) return; } else { if (!oldState.roles.hasNext) return; } state = await AsyncValue.guard(() => _fetch(oldState, onCharacters)); } Future _fetch(StaffRelations oldState, bool? onCharacters) async { final variables = { 'id': arg, 'onList': filter.inLists, 'sort': filter.sort.value, if (filter.ofAnime != null) 'type': filter.ofAnime! ? 'ANIME' : 'MANGA', }; if (onCharacters == null) { variables['withCharacters'] = true; variables['withRoles'] = true; } else if (onCharacters) { variables['withCharacters'] = true; variables['page'] = oldState.charactersAndMedia.next; } else { variables['withRoles'] = true; variables['page'] = oldState.roles.next; } var data = await ref.read(repositoryProvider).request(GqlQuery.staff, variables); data = data['Staff']; final imageQuality = ref.read(persistenceProvider).options.imageQuality; var charactersAndMedia = oldState.charactersAndMedia; var roles = oldState.roles; if (onCharacters == null || onCharacters) { final map = data['characterMedia']; final items = <(StaffRelatedItem, StaffRelatedItem)>[]; for (final m in map['edges']) { final media = StaffRelatedItem.media( m['node'], StringExtension.tryNoScreamingSnakeCase(m['node']['format']), imageQuality, ); for (final c in m['characters']) { if (c == null) continue; items.add(( StaffRelatedItem.character( c, StringExtension.tryNoScreamingSnakeCase(m['characterRole']), ), media, )); } } charactersAndMedia = charactersAndMedia.withNext( items, map['pageInfo']['hasNextPage'] ?? false, ); } if (onCharacters == null || !onCharacters) { final map = data['staffMedia']; final items = []; for (final s in map['edges']) { items.add(StaffRelatedItem.media(s['node'], s['staffRole'], imageQuality)); } roles = roles.withNext(items, map['pageInfo']['hasNextPage'] ?? false); } return StaffRelations(charactersAndMedia: charactersAndMedia, roles: roles); } } ================================================ FILE: lib/feature/staff/staff_roles_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/staff/staff_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/widget/grid/mono_relation_grid.dart'; import 'package:otraku/widget/paged_view.dart'; import 'package:otraku/feature/staff/staff_provider.dart'; class StaffRolesSubview extends StatelessWidget { const StaffRolesSubview({required this.id, required this.scrollCtrl, required this.highContrast}); final int id; final ScrollController scrollCtrl; final bool highContrast; @override Widget build(BuildContext context) { return PagedView( scrollCtrl: scrollCtrl, onRefresh: (invalidate) => invalidate(staffRelationsProvider(id)), provider: staffRelationsProvider( id, ).select((s) => s.unwrapPrevious().whenData((data) => data.roles)), onData: (data) => MonoRelationGrid( items: data.items, onTap: (item) => context.push(Routes.media(item.tileId, item.tileImageUrl)), highContrast: highContrast, ), ); } } ================================================ FILE: lib/feature/staff/staff_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/scroll_controller_extension.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/staff/staff_header.dart'; import 'package:otraku/feature/staff/staff_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/dual_pane_with_tab_bar.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/feature/staff/staff_floating_actions.dart'; import 'package:otraku/feature/staff/staff_characters_view.dart'; import 'package:otraku/feature/staff/staff_overview_view.dart'; import 'package:otraku/feature/staff/staff_provider.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/feature/staff/staff_roles_view.dart'; class StaffView extends ConsumerStatefulWidget { const StaffView(this.id, this.imageUrl); final int id; final String? imageUrl; @override ConsumerState createState() => _StaffViewState(); } class _StaffViewState extends ConsumerState { late final _scrollCtrl = PagedController(loadMore: () {}); @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { ref.listen(staffProvider(widget.id), (_, s) { if (s.hasError) { SnackBarExtension.show(context, 'Failed to load staff: ${s.error}'); } }); final staff = ref.watch(staffProvider(widget.id)); final options = ref.watch(persistenceProvider.select((s) => s.options)); final toggleFavorite = () => ref.read(staffProvider(widget.id).notifier).toggleFavorite(); return AdaptiveScaffold( floatingAction: HidingFloatingActionButton( key: const Key('filter'), scrollCtrl: _scrollCtrl, child: StaffFilterButton(widget.id, ref), ), child: switch (Theming.of(context).formFactor) { .phone => _CompactView( id: widget.id, imageUrl: widget.imageUrl, ref: ref, staff: staff, scrollCtrl: _scrollCtrl, toggleFavorite: toggleFavorite, highContrast: options.highContrast, ), .tablet => _LargeView( id: widget.id, imageUrl: widget.imageUrl, ref: ref, staff: staff, scrollCtrl: _scrollCtrl, toggleFavorite: toggleFavorite, highContrast: options.highContrast, ), }, ); } } class _CompactView extends StatefulWidget { const _CompactView({ required this.id, required this.imageUrl, required this.ref, required this.highContrast, required this.staff, required this.scrollCtrl, required this.toggleFavorite, }); final int id; final String? imageUrl; final WidgetRef ref; final bool highContrast; final AsyncValue staff; final PagedController scrollCtrl; final Future Function() toggleFavorite; @override State<_CompactView> createState() => _CompactViewState(); } class _CompactViewState extends State<_CompactView> with SingleTickerProviderStateMixin { late final _tabCtrl = TabController(length: StaffHeader.tabsWithOverview.length, vsync: this); @override void initState() { super.initState(); widget.scrollCtrl.loadMore = () { if (_tabCtrl.index > 0) { widget.ref.read(staffRelationsProvider(widget.id).notifier).fetch(_tabCtrl.index == 1); } }; } @override void dispose() { _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); final header = StaffHeader.withTabBar( id: widget.id, imageUrl: widget.imageUrl, staff: widget.staff.value, tabCtrl: _tabCtrl, scrollToTop: widget.scrollCtrl.scrollToTop, toggleFavorite: widget.toggleFavorite, highContrast: widget.highContrast, ); return NestedScrollView( controller: widget.scrollCtrl, headerSliverBuilder: (context, _) => [header], body: MediaQuery( data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)), child: widget.staff.unwrapPrevious().when( loading: () => const Center(child: Loader()), error: (_, _) => const Center(child: Text('Failed to load staff')), data: (data) => _StaffTabs.withOverview( id: widget.id, staff: data, tabCtrl: _tabCtrl, highContrast: widget.highContrast, ), ), ), ); } } class _LargeView extends StatefulWidget { const _LargeView({ required this.id, required this.imageUrl, required this.ref, required this.highContrast, required this.staff, required this.scrollCtrl, required this.toggleFavorite, }); final int id; final String? imageUrl; final WidgetRef ref; final bool highContrast; final AsyncValue staff; final PagedController scrollCtrl; final Future Function() toggleFavorite; @override State<_LargeView> createState() => _LargeViewState(); } class _LargeViewState extends State<_LargeView> with SingleTickerProviderStateMixin { late final _tabCtrl = TabController(length: StaffHeader.tabsWithoutOverview.length, vsync: this); @override void initState() { super.initState(); widget.scrollCtrl.loadMore = () { widget.ref.read(staffRelationsProvider(widget.id).notifier).fetch(_tabCtrl.index == 0); }; } @override void dispose() { _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final header = StaffHeader.withoutTabBar( id: widget.id, imageUrl: widget.imageUrl, staff: widget.staff.value, toggleFavorite: widget.toggleFavorite, highContrast: widget.highContrast, ); return DualPaneWithTabBar( tabCtrl: _tabCtrl, scrollToTop: widget.scrollCtrl.scrollToTop, tabs: StaffHeader.tabsWithoutOverview, leftPane: widget.staff.unwrapPrevious().when( loading: () => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ header, const SliverFillRemaining(child: Center(child: Loader())), ], ), error: (_, _) => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ header, const SliverFillRemaining(child: Center(child: Text('Failed to load staff'))), ], ), data: (data) => StaffOverviewSubview.withHeader( staff: data, header: header, invalidate: () => widget.ref.invalidate(staffProvider(widget.id)), highContrast: widget.highContrast, ), ), rightPane: widget.staff.unwrapPrevious().maybeWhen( data: (data) => _StaffTabs.withoutOverview( id: widget.id, staff: data, tabCtrl: _tabCtrl, scrollCtrl: widget.scrollCtrl, highContrast: widget.highContrast, ), orElse: () => const SizedBox(), ), ); } } class _StaffTabs extends ConsumerStatefulWidget { const _StaffTabs.withOverview({ required this.id, required this.staff, required this.tabCtrl, required this.highContrast, }) : withOverview = true, scrollCtrl = null; const _StaffTabs.withoutOverview({ required this.id, required this.staff, required this.tabCtrl, required this.highContrast, required ScrollController this.scrollCtrl, }) : withOverview = false; final int id; final Staff staff; final TabController tabCtrl; final ScrollController? scrollCtrl; final bool highContrast; final bool withOverview; @override ConsumerState<_StaffTabs> createState() => __StaffViewContentState(); } class __StaffViewContentState extends ConsumerState<_StaffTabs> { late final ScrollController _scrollCtrl; double _lastMaxExtent = 0; @override void initState() { super.initState(); _scrollCtrl = widget.scrollCtrl ?? context.findAncestorStateOfType()!.innerController; _scrollCtrl.addListener(_scrollListener); widget.tabCtrl.addListener(_tabListener); } @override void dispose() { _scrollCtrl.removeListener(_scrollListener); widget.tabCtrl.removeListener(_tabListener); super.dispose(); } void _tabListener() { _lastMaxExtent = 0; // This is a workaround for an issue with [NestedScrollView]. // If you switch to a tab with pagination, where the content // doesn't fill the view, the scroll controller has it's maximum // extent set to 0 and the loading of a next page of items is not triggered. // This is why we need to manually load the second page. if (!widget.tabCtrl.indexIsChanging && _scrollCtrl.hasClients) { final pos = _scrollCtrl.positions.last; if (pos.minScrollExtent == pos.maxScrollExtent) _loadNextPage(); } } void _scrollListener() { final pos = _scrollCtrl.positions.last; if (pos.pixels < pos.maxScrollExtent - 100) return; if (_lastMaxExtent == pos.maxScrollExtent) return; _lastMaxExtent = pos.maxScrollExtent; _loadNextPage(); } void _loadNextPage() { final index = widget.withOverview ? widget.tabCtrl.index : widget.tabCtrl.index + 1; if (index > 0) { ref.read(staffRelationsProvider(widget.id).notifier).fetch(index == 1); } } @override Widget build(BuildContext context) { ref.watch(staffRelationsProvider(widget.id).select((_) => null)); final options = ref.watch(persistenceProvider.select((s) => s.options)); return TabBarView( controller: widget.tabCtrl, children: [ if (widget.withOverview) ConstrainedView( padded: false, child: StaffOverviewSubview.asFragment( staff: widget.staff, scrollCtrl: _scrollCtrl, invalidate: () => ref.invalidate(staffProvider(widget.id)), highContrast: widget.highContrast, ), ), StaffCharactersSubview( id: widget.id, scrollCtrl: _scrollCtrl, highContrast: options.highContrast, ), StaffRolesSubview( id: widget.id, scrollCtrl: _scrollCtrl, highContrast: options.highContrast, ), ], ); } } ================================================ FILE: lib/feature/statistics/charts.dart ================================================ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/util/theming.dart'; class BarChart extends StatelessWidget { const BarChart({required this.title, required this.names, required this.values, this.toolbar}) : assert(names.length == values.length); final String title; final List names; final List values; final Widget? toolbar; @override Widget build(BuildContext context) { final colorScheme = ColorScheme.of(context); final textTheme = TextTheme.of(context); return LayoutBuilder( builder: (context, constraints) { final maxBarWidth = constraints.maxWidth; double scale(num value) => value > 0 ? math.log(value + 0.1) : 0; final maxValue = values.fold(0.0, (prev, element) => element > prev ? element : prev); final scaledMaxValue = scale(maxValue); final totalValue = values.fold(0.0, (sum, value) => sum + value); return Column( crossAxisAlignment: .start, children: [ Padding( padding: const .symmetric(vertical: 5), child: Text(title, style: textTheme.titleSmall), ), if (toolbar != null) ...[ SizedBox(width: double.infinity, child: toolbar!), const SizedBox(height: Theming.offset), ], for (int i = 0; i < names.length; i++) Padding( padding: const .symmetric(vertical: 3), child: Column( crossAxisAlignment: .start, spacing: 1, children: [ Row( children: [ Expanded( child: Text(names[i], style: textTheme.labelMedium, textAlign: .left), ), Expanded( child: Text( "${(values[i] / totalValue * 100).toStringAsFixed(1)}%", style: textTheme.labelMedium, textAlign: .center, ), ), Expanded( child: Text( "${values[i]}", style: textTheme.labelMedium, textAlign: .right, ), ), ], ), Container( height: 10, width: maxBarWidth, decoration: BoxDecoration( borderRadius: Theming.borderRadiusSmall, color: colorScheme.surfaceContainerLowest, border: .all(color: colorScheme.outlineVariant, width: 1), ), alignment: .centerLeft, child: AnimatedContainer( duration: const Duration(milliseconds: 200), width: (scale(values[i]) / scaledMaxValue) * maxBarWidth, decoration: BoxDecoration( borderRadius: Theming.borderRadiusSmall, gradient: LinearGradient( begin: .centerLeft, end: .centerRight, colors: [colorScheme.primaryContainer, colorScheme.primary], ), ), ), ), ], ), ), ], ); }, ); } } class PieChart extends StatelessWidget { const PieChart({ required this.title, required this.names, required this.values, required this.highContrast, }) : assert(names.length == values.length); final String title; final List names; final List values; final bool highContrast; @override Widget build(BuildContext context) { final colorScheme = ColorScheme.of(context); final container = CardExtension.highContrast(highContrast)( child: Row( mainAxisSize: MediaQuery.sizeOf(context).width > 420 ? .min : .max, mainAxisAlignment: .spaceBetween, spacing: Theming.offset, children: [ Expanded( child: Padding( padding: const .all(Theming.offset), child: AspectRatio( aspectRatio: 1, child: DecoratedBox( decoration: BoxDecoration( shape: .circle, gradient: RadialGradient( center: const Alignment(-0.5, -0.5), radius: 0.8, colors: [colorScheme.primary, colorScheme.primary.withAlpha(100)], stops: const [0.5, 1.0], ), ), child: CustomPaint(foregroundPainter: _PieLines(colorScheme.surface, values)), ), ), ), ), Expanded( child: ListView.builder( padding: const .only(top: 5, bottom: 5, right: Theming.offset), itemCount: names.length, itemBuilder: (context, i) => Padding( padding: const .symmetric(vertical: 5), child: Row( spacing: 5, children: [ Expanded(child: Text(names[i])), Text(values[i].toString(), style: TextTheme.of(context).labelMedium), ], ), ), ), ), ], ), ); return Column( mainAxisSize: .min, crossAxisAlignment: .start, spacing: 5, children: [ Text(title, style: TextTheme.of(context).titleSmall), Expanded(child: container), ], ); } } /// The lines drawn over the [PieChart] to /// make the [categories] distinguishable. class _PieLines extends CustomPainter { _PieLines(this.colour, this.categories); final Color colour; final List categories; @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = colour ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round ..strokeWidth = 2; double total = 0.0; for (final c in categories) { total += c; } final radius = math.min(size.width, size.height) / 2; final center = Offset(radius, radius); final offset = math.pi * 2 - categories.length * 0.05; double angle = math.pi; for (int i = 0; i < categories.length; i++) { angle -= 0.05 + (categories[i] / total) * offset; final point = Offset( center.dx + radius * math.sin(angle), center.dy + radius * math.cos(angle), ); canvas.drawLine(center, point, paint); } } @override bool shouldRepaint(covariant _PieLines oldDelegate) => false; } ================================================ FILE: lib/feature/statistics/statistics_model.dart ================================================ import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/feature/media/media_models.dart'; class Statistics { Statistics._({ required this.count, required this.meanScore, required this.standardDeviation, required this.partsConsumed, required this.amountConsumed, required this.scores, required this.lengths, required this.formats, required this.statuses, required this.countries, }); factory Statistics(Map map, bool ofAnime) { final scores = []; final lengths = []; final formats = []; final statuses = []; final countries = []; for (final s in map['scores']) { scores.add(AmountStatistics(s, 'score', ofAnime)); } for (final l in map['lengths']) { lengths.add(AmountStatistics(l, 'length', ofAnime)); } for (final f in map['formats']) { formats.add(TypeStatistics(f, 'format')); } for (final s in map['statuses']) { statuses.add(TypeStatistics(s, 'status')); } for (final c in map['countries']) { c['country'] = OriginCountry.fromCode(c['country'])?.label; countries.add(TypeStatistics(c, 'country')); } // The backend can't sort them by length, so it has to be done locally. lengths.sort((a, b) { if (a.type == '?') return 1; if (b.type == '?') return -1; if (a.type[a.type.length - 1] == '+') return 1; if (b.type[b.type.length - 1] == '+') return -1; if (a.type.length > b.type.length) return 1; if (a.type.length < b.type.length) return -1; return a.type.compareTo(b.type); }); return Statistics._( count: map['count'], meanScore: map['meanScore'].toDouble(), standardDeviation: map['standardDeviation'].toDouble(), partsConsumed: ofAnime ? map['episodesWatched'] : map['chaptersRead'], amountConsumed: ofAnime ? map['minutesWatched'] : map['volumesRead'], scores: scores, lengths: lengths, formats: formats, statuses: statuses, countries: countries, ); } final int count; final double meanScore; final double standardDeviation; final int partsConsumed; final int amountConsumed; final List scores; final List lengths; final List formats; final List statuses; final List countries; } class AmountStatistics { AmountStatistics._({ required this.count, required this.meanScore, required this.amount, required this.type, }); factory AmountStatistics(Map map, String key, bool ofAnime) => AmountStatistics._( count: map['count'], meanScore: map['meanScore'].toDouble(), amount: ofAnime ? map['minutesWatched'] ~/ 60 : map['chaptersRead'], type: (map[key] ?? '?').toString(), ); final int count; final double meanScore; final int amount; final String type; } class TypeStatistics { TypeStatistics._({ required this.count, required this.meanScore, required this.hoursWatched, required this.chaptersRead, required this.value, }); factory TypeStatistics(Map map, String key) => TypeStatistics._( count: map['count'], meanScore: map['meanScore'].toDouble(), hoursWatched: map['minutesWatched'] ~/ 60, chaptersRead: map['chaptersRead'], value: (map[key] as String).noScreamingSnakeCase, ); final int count; final double meanScore; final int hoursWatched; final int chaptersRead; final String value; } ================================================ FILE: lib/feature/statistics/statistics_view.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/extension/scroll_controller_extension.dart'; import 'package:otraku/feature/statistics/statistics_model.dart'; import 'package:otraku/feature/user/user_model.dart'; import 'package:otraku/feature/user/user_providers.dart'; import 'package:otraku/feature/statistics/charts.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/loaders.dart'; class StatisticsView extends StatefulWidget { const StatisticsView(this.id); final int id; @override State createState() => _StatisticsViewState(); } class _StatisticsViewState extends State with SingleTickerProviderStateMixin { late final tag = idUserTag(widget.id); late final _tabCtrl = TabController(length: 2, vsync: this); final _scrollCtrl = ScrollController(); int _primaryBarChartTab = 0; int _secondaryBarChartTab = 0; @override void initState() { super.initState(); _tabCtrl.addListener(() => setState(() {})); } @override void dispose() { _tabCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final child = Consumer( builder: (context, ref, _) { ref.listen>( userProvider(tag), (_, s) => s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())), ); final options = ref.watch(persistenceProvider.select((s) => s.options)); return ref .watch(userProvider(tag)) .when( loading: () => const Center(child: Loader()), error: (_, _) => const Center(child: Text('Failed to load statistics')), data: (data) { return TabBarView( controller: _tabCtrl, children: [ ConstrainedView( child: _StatisticsView( statistics: data.animeStats, ofAnime: true, scrollCtrl: _scrollCtrl, primaryBarChartTab: () => _primaryBarChartTab, secondaryBarChartTab: () => _secondaryBarChartTab, onPrimaryTabChanged: (i) => _primaryBarChartTab = i, onSecondaryTabChanged: (i) => _secondaryBarChartTab = i, highContrast: options.highContrast, ), ), ConstrainedView( child: _StatisticsView( statistics: data.mangaStats, ofAnime: false, scrollCtrl: _scrollCtrl, primaryBarChartTab: () => _primaryBarChartTab, secondaryBarChartTab: () => _secondaryBarChartTab, onPrimaryTabChanged: (i) => _primaryBarChartTab = i, onSecondaryTabChanged: (i) => _secondaryBarChartTab = i, highContrast: options.highContrast, ), ), ], ); }, ); }, ); return AdaptiveScaffold( topBar: _tabCtrl.index == 0 ? const TopBar(key: Key('0'), title: 'Anime Statistics') : const TopBar(key: Key('1'), title: 'Manga Statistics'), navigationConfig: NavigationConfig( selected: _tabCtrl.index, onChanged: (i) => _tabCtrl.index = i, onSame: (_) => _scrollCtrl.scrollToTop(), items: const {'Anime': Ionicons.film_outline, 'Manga': Ionicons.book_outline}, ), child: child, ); } } class _StatisticsView extends StatelessWidget { const _StatisticsView({ required this.statistics, required this.ofAnime, required this.scrollCtrl, required this.primaryBarChartTab, required this.secondaryBarChartTab, required this.onPrimaryTabChanged, required this.onSecondaryTabChanged, required this.highContrast, }); final Statistics statistics; final bool ofAnime; final ScrollController scrollCtrl; final int Function() primaryBarChartTab; final int Function() secondaryBarChartTab; final void Function(int) onPrimaryTabChanged; final void Function(int) onSecondaryTabChanged; final bool highContrast; @override Widget build(BuildContext context) { const spacing = SliverToBoxAdapter(child: SizedBox(height: Theming.offset)); return CustomScrollView( controller: scrollCtrl, slivers: [ SliverToBoxAdapter( child: SizedBox(height: MediaQuery.paddingOf(context).top + Theming.offset), ), _Details(statistics, ofAnime, highContrast), if (statistics.scores.isNotEmpty) ...[ spacing, _BarChart( title: 'Score', statistics: statistics.scores, ofAnime: ofAnime, full: false, initialTab: primaryBarChartTab(), onTabChanged: onPrimaryTabChanged, ), ], if (statistics.lengths.isNotEmpty) ...[ spacing, _BarChart( title: ofAnime ? 'Episodes' : 'Chapters', statistics: statistics.lengths, ofAnime: ofAnime, full: true, initialTab: secondaryBarChartTab(), onTabChanged: onSecondaryTabChanged, ), ], if (statistics.count > 0) ...[ spacing, SliverGrid( gridDelegate: const SliverGridDelegateWithMinWidthAndFixedHeight( minWidth: 340, height: 200, ), delegate: SliverChildListDelegate([ _PieChart('Format Distribution', statistics.formats, highContrast), _PieChart('Status Distribution', statistics.statuses, highContrast), _PieChart('Country Distribution', statistics.countries, highContrast), ]), ), ], const SliverFooter(), ], ); } } class _Details extends StatelessWidget { _Details(Statistics statistics, bool ofAnime, this.highContrast) { subtitles.add(statistics.count); subtitles.add(statistics.partsConsumed); if (ofAnime) { subtitles.add(((statistics.amountConsumed / 1440) * 10).round() / 10); icons.add(Ionicons.film_outline); icons.add(Ionicons.play_outline); icons.add(Ionicons.calendar_clear_outline); titles.add('Total Anime'); titles.add('Episodes Watched'); titles.add('Days Watched'); } else { subtitles.add(statistics.amountConsumed); icons.add(Ionicons.book_outline); icons.add(Ionicons.reader_outline); icons.add(Ionicons.bookmark_outline); titles.add('Total Manga'); titles.add('Chapters Read'); titles.add('Volumes Read'); } icons.add(Ionicons.star_half_outline); icons.add(Ionicons.calculator_outline); titles.add('Mean Score'); titles.add('Standard Deviation'); subtitles.add(statistics.meanScore); subtitles.add(statistics.standardDeviation); } final icons = []; final titles = []; final subtitles = []; final bool highContrast; @override Widget build(BuildContext context) { final textTheme = TextTheme.of(context); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!); final tileHeight = max(bodyMediumLineHeight + labelMediumLineHeight, Theming.iconBig) + 10; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight( minWidth: 190, height: tileHeight, mainAxisSpacing: 10, crossAxisSpacing: 10, ), delegate: SliverChildBuilderDelegate( childCount: titles.length, (context, i) => Tooltip( message: titles[i], triggerMode: .tap, child: CardExtension.highContrast(highContrast)( child: Padding( padding: const .symmetric(horizontal: Theming.offset, vertical: 5), child: Row( spacing: Theming.offset, children: [ Icon(icons[i], size: Theming.iconBig), Expanded( child: Column( mainAxisAlignment: .center, crossAxisAlignment: .start, children: [ Expanded( child: Text( titles[i], style: TextTheme.of(context).labelMedium, overflow: .ellipsis, maxLines: 1, ), ), Text(subtitles[i].toString()), ], ), ), ], ), ), ), ), ), ); } } class _BarChart extends StatefulWidget { const _BarChart({ required this.statistics, required this.title, required this.initialTab, required this.ofAnime, required this.full, required this.onTabChanged, }); final List statistics; final String title; final int initialTab; final bool ofAnime; final bool full; final void Function(int) onTabChanged; @override State<_BarChart> createState() => _BarChartState(); } class _BarChartState extends State<_BarChart> { late int _tab = widget.initialTab; @override Widget build(BuildContext context) { late List values; if (_tab == 0) { values = widget.statistics.map((s) => s.count).toList(); } else if (_tab == 1) { values = widget.statistics.map((s) => s.amount).toList(); } else { values = widget.statistics.map((s) => s.meanScore).toList(); } return SliverToBoxAdapter( child: BarChart( title: widget.title, toolbar: SegmentedButton( segments: [ const ButtonSegment( value: 0, label: Text('Titles'), icon: Icon(Icons.numbers_outlined), ), if (widget.ofAnime) const ButtonSegment( value: 1, label: Text('Hours'), icon: Icon(Icons.hourglass_bottom_outlined), ) else const ButtonSegment( value: 1, label: Text('Chapters'), icon: Icon(Icons.hourglass_bottom_outlined), ), if (widget.full && widget.statistics.any((s) => s.meanScore > 0)) const ButtonSegment( value: 2, label: Text('Score'), icon: Icon(Icons.star_half_outlined), ), ], selected: {_tab}, onSelectionChanged: (v) { setState(() => _tab = v.first); widget.onTabChanged(v.first); }, ), names: widget.statistics.map((s) => s.type).toList(), values: values, ), ); } } class _PieChart extends StatelessWidget { const _PieChart(this.title, this.stats, this.highContrast); final String title; final List stats; final bool highContrast; @override Widget build(BuildContext context) { final names = stats.map((s) => s.value).toList(); final values = stats.map((s) => s.count).toList(); return PieChart(title: title, names: names, values: values, highContrast: highContrast); } } ================================================ FILE: lib/feature/studio/studio_filter_model.dart ================================================ import 'package:otraku/feature/media/media_models.dart'; class StudioFilter { const StudioFilter({this.sort = .startDateDesc, this.inLists, this.isMain}); final MediaSort sort; final bool? inLists; final bool? isMain; StudioFilter copyWith({MediaSort? sort, (bool?,)? inLists, (bool?,)? isMain}) => StudioFilter( sort: sort ?? this.sort, inLists: inLists == null ? this.inLists : inLists.$1, isMain: isMain == null ? this.isMain : isMain.$1, ); } ================================================ FILE: lib/feature/studio/studio_filter_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/studio/studio_filter_model.dart'; final studioFilterProvider = NotifierProvider.autoDispose .family(StudioFilterNotifier.new); class StudioFilterNotifier extends Notifier { StudioFilterNotifier(this.arg); final int arg; @override StudioFilter build() => const StudioFilter(); @override set state(StudioFilter newState) => super.state = newState; } ================================================ FILE: lib/feature/studio/studio_floating_actions.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/widget/input/chip_selector.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/studio/studio_filter_provider.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/sheets.dart'; class StudioFilterButton extends StatelessWidget { const StudioFilterButton(this.id, this.ref); final int id; final WidgetRef ref; @override Widget build(BuildContext context) { return FloatingActionButton( tooltip: 'Filter', heroTag: 'filter', child: const Icon(Ionicons.funnel_outline), onPressed: () { var filter = ref.read(studioFilterProvider(id)); final onDone = (_) => ref.read(studioFilterProvider(id).notifier).state = filter; final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast)); showSheet( context, SimpleSheet( initialHeight: Theming.normalTapTarget * 4 + MediaQuery.paddingOf(context).bottom + 40, builder: (context, scrollCtrl) => ListView( controller: scrollCtrl, physics: Theming.bouncyPhysics, padding: const .symmetric(horizontal: Theming.offset, vertical: 20), children: [ ChipSelector.ensureSelected( title: 'Sort', items: MediaSort.values.map((v) => (v.label, v)).toList(), value: filter.sort, onChanged: (v) => filter = filter.copyWith(sort: v), highContrast: highContrast, ), ChipSelector( title: 'List Presence', items: const [('In Lists', true), ('Not in Lists', false)], value: filter.inLists, onChanged: (v) => filter = filter.copyWith(inLists: (v,)), highContrast: highContrast, ), ChipSelector( title: 'Main Studio', items: const [('Is Main', true), ('Is Not Main', false)], value: filter.isMain, onChanged: (v) => filter = filter.copyWith(isMain: (v,)), highContrast: highContrast, ), ], ), ), ).then(onDone); }, ); } } ================================================ FILE: lib/feature/studio/studio_header.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/studio/studio_model.dart'; import 'package:otraku/widget/layout/content_header.dart'; class StudioHeader extends StatelessWidget { const StudioHeader({ required this.id, required this.name, required this.studio, required this.toggleFavorite, }); final int id; final String? name; final Studio? studio; final Future Function() toggleFavorite; @override Widget build(BuildContext context) { final name = studio?.name ?? this.name; return CustomContentHeader( title: name, siteUrl: studio?.siteUrl, trailingTopButtons: studio != null ? [_FavoriteButton(studio!, toggleFavorite)] : const [], content: PreferredSize( preferredSize: const Size.fromHeight(80), child: Column( crossAxisAlignment: .stretch, children: [ if (name != null) Flexible( child: GestureDetector( onTap: () => SnackBarExtension.copy(context, name), child: Hero( tag: id, child: Text( name, textAlign: .center, overflow: .ellipsis, style: TextTheme.of(context).titleMedium, ), ), ), ), if (studio != null) Flexible(child: Text('${studio!.favorites} Favorites', textAlign: .center)), ], ), ), ); } } class _FavoriteButton extends StatefulWidget { const _FavoriteButton(this.studio, this.toggleFavorite); final Studio studio; final Future Function() toggleFavorite; @override State<_FavoriteButton> createState() => __FavoriteButtonState(); } class __FavoriteButtonState extends State<_FavoriteButton> { @override Widget build(BuildContext context) { final studio = widget.studio; return IconButton( tooltip: studio.isFavorite ? 'Unfavourite' : 'Favourite', icon: studio.isFavorite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border), onPressed: () async { setState(() => studio.isFavorite = !studio.isFavorite); final err = await widget.toggleFavorite(); if (err == null) return; setState(() => studio.isFavorite = !studio.isFavorite); if (context.mounted) SnackBarExtension.show(context, err.toString()); }, ); } } ================================================ FILE: lib/feature/studio/studio_item_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/studio/studio_item_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; class StudioItemGrid extends StatelessWidget { const StudioItemGrid(this.items, {required this.highContrast}); final List items; final bool highContrast; @override Widget build(BuildContext context) { final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight( minWidth: 230, height: lineHeight + 20, mainAxisSpacing: 10, crossAxisSpacing: 10, ), delegate: SliverChildBuilderDelegate( childCount: items.length, (_, i) => InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => context.push(Routes.studio(items[i].id, items[i].name)), child: CardExtension.highContrast(highContrast)( child: Padding( padding: Theming.paddingAll, child: Hero( tag: items[i].id, child: Text( items[i].name, style: TextTheme.of(context).bodyMedium, overflow: .ellipsis, maxLines: 1, ), ), ), ), ), ), ); } } ================================================ FILE: lib/feature/studio/studio_item_model.dart ================================================ class StudioItem { const StudioItem._({required this.id, required this.name}); factory StudioItem(Map map) => StudioItem._(id: map['id'], name: map['name']); final int id; final String name; } ================================================ FILE: lib/feature/studio/studio_model.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; class Studio { Studio._({ required this.id, required this.name, required this.siteUrl, required this.favorites, required this.isFavorite, }); factory Studio(Map map) => Studio._( id: map['id'], name: map['name'], siteUrl: map['siteUrl'], favorites: map['favourites'] ?? 0, isFavorite: map['isFavourite'] ?? false, ); final int id; final String name; final String siteUrl; final int favorites; bool isFavorite; } class StudioMedia { const StudioMedia._({ required this.id, required this.title, required this.cover, required this.format, required this.releaseStatus, required this.weightedAverageScore, required this.entryStatus, required this.startDate, }); factory StudioMedia(Map map, ImageQuality imageQuality) => StudioMedia._( id: map['id'], title: map['title']['userPreferred'], cover: map['coverImage'][imageQuality.value], format: MediaFormat.from(map['format']), releaseStatus: ReleaseStatus.from(map['status']), weightedAverageScore: map['averageScore'] ?? 0, entryStatus: ListStatus.from(map['mediaListEntry']?['status']), startDate: DateTimeExtension.fuzzyDateString(map['startDate']), ); final int id; final String title; final String cover; final MediaFormat? format; final ReleaseStatus? releaseStatus; final int weightedAverageScore; final ListStatus? entryStatus; final String? startDate; } ================================================ FILE: lib/feature/studio/studio_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/feature/studio/studio_filter_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/feature/studio/studio_filter_provider.dart'; import 'package:otraku/feature/studio/studio_model.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final studioProvider = AsyncNotifierProvider.autoDispose.family( StudioNotifier.new, ); final studioMediaProvider = AsyncNotifierProvider.autoDispose .family, int>(StudioMediaNotifier.new); class StudioNotifier extends AsyncNotifier { StudioNotifier(this.arg); final int arg; @override FutureOr build() async { final data = await ref.read(repositoryProvider).request(GqlQuery.studio, { 'id': arg, 'withInfo': true, }); return Studio(data['Studio']); } Future toggleFavorite() { return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, { 'studio': arg, }).getErrorOrNull(); } } class StudioMediaNotifier extends AsyncNotifier> { StudioMediaNotifier(this.arg); final int arg; late StudioFilter filter; @override FutureOr> build() async { filter = ref.watch(studioFilterProvider(arg)); return await _fetch(const Paged()); } Future fetch() async { final oldState = state.value ?? const Paged(); if (!oldState.hasNext) return; state = await AsyncValue.guard(() => _fetch(oldState)); } Future> _fetch(Paged oldState) async { final data = await ref.read(repositoryProvider).request(GqlQuery.studio, { 'id': arg, 'withMedia': true, 'page': oldState.next, 'sort': filter.sort.value, 'onList': filter.inLists, if (filter.isMain != null) 'isMain': filter.isMain, }); final imageQuality = ref.read(persistenceProvider).options.imageQuality; final map = data['Studio']['media']; final items = []; for (final m in map['nodes']) { items.add(StudioMedia(m, imageQuality)); } return oldState.withNext(items, map['pageInfo']['hasNextPage'] ?? false); } } ================================================ FILE: lib/feature/studio/studio_view.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/media/media_route_tile.dart'; import 'package:otraku/feature/studio/studio_floating_actions.dart'; import 'package:otraku/feature/studio/studio_header.dart'; import 'package:otraku/feature/studio/studio_model.dart'; import 'package:otraku/feature/studio/studio_provider.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/paged_controller.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/text_rail.dart'; class StudioView extends ConsumerStatefulWidget { const StudioView(this.id, this.name); final int id; final String? name; @override ConsumerState createState() => _StudioViewState(); } class _StudioViewState extends ConsumerState { late final _scrollCtrl = PagedController( loadMore: () { ref.read(studioMediaProvider(widget.id).notifier).fetch(); }, ); @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { ref.listen( studioMediaProvider(widget.id), (_, s) => s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())), ); final studio = ref.watch(studioProvider(widget.id)).value; final studioMedia = ref.watch(studioMediaProvider(widget.id)); final options = ref.watch(persistenceProvider.select((s) => s.options)); final mediaQuery = MediaQuery.of(context); final header = StudioHeader( id: widget.id, name: studio?.name ?? widget.name, studio: studio, toggleFavorite: () => ref.read(studioProvider(widget.id).notifier).toggleFavorite(), ); final content = studioMedia.unwrapPrevious().when( loading: () => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ header, const SliverFillRemaining(child: Center(child: Loader())), ], ), error: (_, _) => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ header, const SliverFillRemaining(child: Center(child: Text('Failed to load studio'))), ], ), data: (data) => CustomScrollView( physics: Theming.bouncyPhysics, controller: _scrollCtrl, slivers: [ header, MediaQuery( data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)), child: SliverRefreshControl( onRefresh: () { ref.invalidate(studioProvider(widget.id)); ref.invalidate(studioMediaProvider(widget.id)); }, ), ), SliverConstrainedView(sliver: _StudioMediaGrid(data.items, options.highContrast)), SliverFooter(loading: data.hasNext), ], ), ); return AdaptiveScaffold( floatingAction: studio != null ? HidingFloatingActionButton( key: const Key('filter'), scrollCtrl: _scrollCtrl, child: StudioFilterButton(widget.id, ref), ) : null, child: content, ); }, ); } } class _StudioMediaGrid extends StatelessWidget { const _StudioMediaGrid(this.items, this.highContrast); final List items; final bool highContrast; @override Widget build(BuildContext context) { final textTheme = TextTheme.of(context); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!); final labelSmallLineHeight = context.lineHeight(textTheme.labelSmall!); final tileHeight = bodyMediumLineHeight * 2 + labelMediumLineHeight + max(labelSmallLineHeight, 15) + 20; final coverWidth = tileHeight / Theming.coverHtoWRatio; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 260, height: tileHeight), delegate: SliverChildBuilderDelegate( childCount: items.length, (context, i) => _MediaTile(items[i], highContrast, coverWidth), ), ); } } class _MediaTile extends StatelessWidget { const _MediaTile(this.item, this.highContrast, this.coverWidth); final StudioMedia item; final bool highContrast; final double coverWidth; @override Widget build(BuildContext context) { final theme = Theme.of(context); final textRailItems = { if (item.format != null) item.format!.label: false, if (item.entryStatus != null) item.entryStatus!.label(true): true, if (item.releaseStatus != null) item.releaseStatus!.label: false, }; return MediaRouteTile( id: item.id, imageUrl: item.cover, child: CardExtension.highContrast(highContrast)( child: Row( mainAxisAlignment: .start, children: [ Hero( tag: item.id, child: ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: DecoratedBox( decoration: BoxDecoration(color: theme.colorScheme.surfaceContainerHighest), child: CachedImage(item.cover, width: coverWidth), ), ), ), Expanded( child: Padding( padding: .symmetric(horizontal: Theming.offset, vertical: 5), child: Column( crossAxisAlignment: .start, mainAxisAlignment: .spaceEvenly, spacing: 5, children: [ Flexible(child: Text(item.title, overflow: .ellipsis, maxLines: 2)), TextRail(textRailItems, style: theme.textTheme.labelMedium), if (item.startDate != null) Row( children: [ Expanded( child: Text( item.startDate!, style: theme.textTheme.labelSmall!.copyWith( color: theme.colorScheme.primary, ), ), ), Expanded( child: Row( mainAxisSize: .min, spacing: 5, children: [ Icon( Icons.percent_rounded, size: 15, color: theme.colorScheme.onSurfaceVariant, ), Text( item.weightedAverageScore.toString(), style: theme.textTheme.labelSmall, ), ], ), ), ], ), ], ), ), ), ], ), ), ); } } ================================================ FILE: lib/feature/tag/tag_model.dart ================================================ import 'dart:collection'; import 'package:otraku/extension/iterable_extension.dart'; class Tag { final String name; final String desciption; final bool isSpoiler; final int? rank; Tag._({ required this.name, required this.rank, required this.desciption, required this.isSpoiler, }); factory Tag(Map map) => Tag._( name: map['name'], rank: map['rank'], desciption: map['description'] ?? 'No description', isSpoiler: map['isMediaSpoiler'] ?? false, ); } /// Stores all tags (genres as treated as tags too). class TagCollection { TagCollection._({ required this.categories, required this.ids, required this.names, required this.descriptions, required this.indexByName, }); factory TagCollection(Map map) { final categories = [(name: _genreCategoryName, indexes: [])]; final ids = []; final names = []; final descriptions = []; final indexByName = HashMap(); /// Genres are given negative ids, as /// to not get mixed up with normal tags. int id = -1; for (final g in map['GenreCollection']) { categories[0].indexes.add(ids.length); ids.add(id); names.add(g.toString()); descriptions.add(''); indexByName.putIfAbsent(names.last, () => names.length - 1); id--; } for (final t in map['MediaTagCollection']) { String categoryName = t['category'] != null ? (t['category'] as String).replaceFirst('-', '/') : 'Other'; if (categoryName.isEmpty) categoryName = 'Other'; var category = categories.firstWhereOrNull((c) => c.name == categoryName); if (category == null) { category = (name: categoryName, indexes: []); categories.add(category); } category.indexes.add(ids.length); ids.add(t['id']); names.add(t['name']); descriptions.add(t['description'] ?? ''); indexByName.putIfAbsent(names.last, () => names.length - 1); } // Sort categories alphabetically. // Genres must be at the front, while the adult category must be last. categories.sort((a, b) { if (a.name == _genreCategoryName) return -1; if (a.name == _adultCategoryName) return 1; if (b.name == _genreCategoryName) return 1; if (b.name == _adultCategoryName) return -1; return a.name.compareTo(b.name); }); return TagCollection._( categories: categories, ids: ids, names: names, descriptions: descriptions, indexByName: indexByName, ); } static const _genreCategoryName = 'Genres'; static const _adultCategoryName = 'Sexual Content'; /// Each category has a name and a list of indices for its tags. final List<({String name, List indexes})> categories; /// Tag data. final List ids; final List names; final List descriptions; final HashMap indexByName; } ================================================ FILE: lib/feature/tag/tag_picker.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/iterable_extension.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/input/search_field.dart'; import 'package:otraku/widget/input/stateful_tiles.dart'; import 'package:otraku/widget/grid/chip_grid.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/widget/shadowed_overflow_list.dart'; import 'package:otraku/feature/tag/tag_model.dart'; import 'package:otraku/feature/tag/tag_provider.dart'; class TagPicker extends StatefulWidget { const TagPicker({ required this.includedGenres, required this.excludedGenres, required this.includedTags, required this.excludedTags, }); final List includedGenres; final List excludedGenres; final List includedTags; final List excludedTags; @override TagPickerState createState() => TagPickerState(); } class TagPickerState extends State { @override Widget build(BuildContext context) { final children = []; for (final name in widget.includedGenres) { children.add( _DualStateTagChip( key: Key(name), label: name, positive: true, onChanged: (positive) => _toggleGenre(name, positive), onRemoved: () => setState(() => widget.includedGenres.remove(name)), ), ); } for (final name in widget.excludedGenres) { children.add( _DualStateTagChip( key: Key(name), label: name, positive: false, onChanged: (positive) => _toggleGenre(name, positive), onRemoved: () => setState(() => widget.excludedGenres.remove(name)), ), ); } for (final name in widget.includedTags) { children.add( _DualStateTagChip( key: Key(name), label: name, positive: true, onChanged: (positive) => _toggleTag(name, positive), onRemoved: () => setState(() => widget.includedTags.remove(name)), ), ); } for (final name in widget.excludedTags) { children.add( _DualStateTagChip( key: Key(name), label: name, positive: false, onChanged: (positive) => _toggleTag(name, positive), onRemoved: () => setState(() => widget.excludedTags.remove(name)), ), ); } return ChipGrid( title: 'Tags', placeholder: 'tags', children: children, onEdit: () => showSheet( context, SimpleSheet( builder: (context, scrollCtrl) => Consumer( builder: (context, ref, child) { TagCollection tags; switch (ref.watch(tagsProvider)) { case AsyncData(:final value): tags = value; break; case AsyncError(:final error): return Center( child: Padding( padding: Theming.paddingAll, child: Text('Failed to load tags: ${error.toString()}'), ), ); case AsyncLoading(): return const Center(child: Loader()); } return _FilterTagSheet( tags: tags, includedGenres: widget.includedGenres, excludedGenres: widget.excludedGenres, includedTags: widget.includedTags, excludedTags: widget.excludedTags, scrollCtrl: scrollCtrl, ); }, ), ), ).then((_) => setState(() {})), onClear: () => setState(() { widget.includedGenres.clear(); widget.excludedGenres.clear(); widget.includedTags.clear(); widget.excludedTags.clear(); }), ); } void _toggleGenre(String name, bool positive) { if (positive) { widget.includedGenres.add(name); widget.excludedGenres.remove(name); } else { widget.excludedGenres.add(name); widget.includedGenres.remove(name); } } void _toggleTag(String name, bool positive) { if (positive) { widget.includedTags.add(name); widget.excludedTags.remove(name); } else { widget.excludedTags.add(name); widget.includedTags.remove(name); } } } class _DualStateTagChip extends StatefulWidget { const _DualStateTagChip({ required super.key, required this.label, required this.positive, required this.onChanged, required this.onRemoved, }); final String label; final bool positive; final void Function(bool) onChanged; final void Function() onRemoved; @override State<_DualStateTagChip> createState() => _DualStateTagChipState(); } class _DualStateTagChipState extends State<_DualStateTagChip> { late bool _positive = widget.positive; @override Widget build(BuildContext context) { return InputChip( label: Text(widget.label), labelStyle: TextStyle( color: _positive ? ColorScheme.of(context).onSecondaryContainer : ColorScheme.of(context).onErrorContainer, ), deleteIconColor: _positive ? ColorScheme.of(context).onSecondaryContainer : ColorScheme.of(context).onErrorContainer, backgroundColor: _positive ? ColorScheme.of(context).secondaryContainer : ColorScheme.of(context).errorContainer, onDeleted: widget.onRemoved, onPressed: () { setState(() => _positive = !_positive); widget.onChanged(_positive); }, ); } } class _FilterTagSheet extends ConsumerStatefulWidget { const _FilterTagSheet({ required this.tags, required this.includedGenres, required this.excludedGenres, required this.includedTags, required this.excludedTags, required this.scrollCtrl, }); final TagCollection tags; final List includedGenres; final List excludedGenres; final List includedTags; final List excludedTags; final ScrollController scrollCtrl; @override ConsumerState<_FilterTagSheet> createState() => _FilterTagSheetState(); } class _FilterTagSheetState extends ConsumerState<_FilterTagSheet> { late final List _itemIndexes; late final List _categoryIndexes; String _filter = ''; int _index = 0; @override void initState() { super.initState(); _itemIndexes = [...widget.tags.categories[_index].indexes]; _categoryIndexes = List.generate(widget.tags.categories.length, (i) => i); } @override Widget build(BuildContext context) { late final List included; late final List excluded; if (_categoryIndexes.isNotEmpty && _categoryIndexes[_index] == 0) { included = widget.includedGenres; excluded = widget.excludedGenres; } else { included = widget.includedTags; excluded = widget.excludedTags; } return Stack( children: [ if (_itemIndexes.isNotEmpty) Material( color: Colors.transparent, child: ListView.builder( padding: .only(top: 110, bottom: MediaQuery.paddingOf(context).bottom), controller: widget.scrollCtrl, itemCount: _itemIndexes.length, itemExtent: 56, itemBuilder: (_, i) { final name = widget.tags.names[_itemIndexes[i]]; return StatefulCheckboxListTile( title: Text(name), tristate: true, value: included.contains(name) ? true : excluded.contains(name) ? null : false, onChanged: (v) { if (v == null) { included.remove(name); excluded.add(name); } else if (v) { included.add(name); } else { excluded.remove(name); } }, ); }, ), ) else const Center(child: Text('No Results')), ClipRRect( borderRadius: const BorderRadius.vertical(top: Theming.radiusBig), child: BackdropFilter( filter: Theming.blurFilter, child: Container( height: 110, color: Theme.of(context).navigationBarTheme.backgroundColor, padding: const .symmetric(vertical: Theming.offset), child: Column( children: [ Padding( padding: const .only( left: Theming.offset, right: Theming.offset, bottom: Theming.offset, ), child: SearchField(hint: 'Tag', value: _filter, onChanged: _onSearch), ), SizedBox( height: 40, child: ShadowedOverflowList( itemCount: _categoryIndexes.length, itemBuilder: _categoryChipBuilder, ), ), ], ), ), ), ), ], ); } void _onSearch(String val) { final tags = widget.tags; _filter = val.toLowerCase(); _categoryIndexes.clear(); _itemIndexes.clear(); for (int i = 0; i < tags.categories.length; i++) { final matchingTag = tags.categories[i].indexes.firstWhereOrNull( (index) => tags.names[index].toLowerCase().contains(_filter), ); if (matchingTag != null) { _categoryIndexes.add(i); } } if (_categoryIndexes.isEmpty) { _index = 0; setState(() {}); return; } if (_index >= _categoryIndexes.length) { _index = _categoryIndexes.length - 1; } for (final i in tags.categories[_categoryIndexes[_index]].indexes) { if (tags.names[i].toLowerCase().contains(_filter)) { _itemIndexes.add(i); } } setState(() {}); } Widget _categoryChipBuilder(BuildContext context, int i) { final tags = widget.tags; return _TagCategoryChip( name: tags.categories[_categoryIndexes[i]].name, selected: i == _index, onTap: () { if (_index == i) return; _index = i; _itemIndexes.clear(); for (final i in tags.categories[_categoryIndexes[_index]].indexes) { if (tags.names[i].toLowerCase().contains(_filter)) { _itemIndexes.add(i); } } setState(() {}); }, ); } } class _TagCategoryChip extends StatelessWidget { const _TagCategoryChip({required this.name, required this.selected, required this.onTap}); final String name; final bool selected; final void Function() onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Chip( label: Text(name), labelStyle: selected ? TextTheme.of(context).bodyMedium?.copyWith(color: ColorScheme.of(context).surface) : TextTheme.of(context).bodyMedium, backgroundColor: selected ? ColorScheme.of(context).primary : ColorScheme.of(context).onSecondary, side: selected ? BorderSide(color: ColorScheme.of(context).primary) : BorderSide(color: ColorScheme.of(context).onSurface), ), ); } } ================================================ FILE: lib/feature/tag/tag_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/util/graphql.dart'; import 'package:otraku/feature/tag/tag_model.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; final tagsProvider = FutureProvider( (ref) async => TagCollection(await ref.read(repositoryProvider).request(GqlQuery.genresAndTags)), ); ================================================ FILE: lib/feature/thread/thread_model.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/feature/comment/comment_model.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/util/markdown.dart'; class Thread { const Thread._({ required this.info, required this.comments, required this.commentPage, required this.totalCommentPages, }); factory Thread(Map map, ImageQuality imageQuality) => Thread._withInfo(ThreadInfo(map['Thread'], imageQuality), map); factory Thread._withInfo(ThreadInfo info, Map map) { final comments = []; for (final c in map['Page']?['threadComments'] ?? const []) { comments.add(Comment(c)); } return Thread._( info: info, comments: comments, commentPage: map['Page']?['pageInfo']?['currentPage'] ?? 1, totalCommentPages: map['Page']?['pageInfo']?['lastPage'] ?? 1, ); } final ThreadInfo info; final List comments; final int commentPage; final int totalCommentPages; Thread withChangingCommentPage(int commentPage) => Thread._( info: info, comments: comments, commentPage: commentPage, totalCommentPages: totalCommentPages, ); Thread withChangedCommentPage(Map map) => Thread._withInfo(info, map); Thread withAppendedComment(Map map, int? parentCommentId) { if (parentCommentId == null) { return Thread._( info: info, commentPage: commentPage, totalCommentPages: totalCommentPages, comments: [...comments, Comment(map)], ); } for (final comment in comments) { if (comment.append(map, parentCommentId)) { return Thread._( info: info, commentPage: commentPage, totalCommentPages: totalCommentPages, comments: [...comments], ); } } return this; } } class ThreadInfo { ThreadInfo._({ required this.id, required this.title, required this.body, required this.viewCount, required this.replyCount, required this.likeCount, required this.isLiked, required this.isSubscribed, required this.isPinned, required this.isLocked, required this.siteUrl, required this.createdAt, required this.categories, required this.media, required this.userId, required this.userName, required this.userAvatarUrl, }); factory ThreadInfo(Map map, ImageQuality imageQuality) { final categories = []; for (final c in map['categories'] ?? const []) { categories.add(c['name']); } final media = []; for (final m in map['mediaCategories'] ?? const []) { media.add(( id: m['id'] ?? 0, title: m['title']?['userPreferred'] ?? '', coverUrl: m['coverImage']?[imageQuality.value] ?? '', )); } return ThreadInfo._( id: map['id'], title: map['title'] ?? '?', body: parseMarkdown(map['body'] ?? ''), viewCount: map['viewCount'] ?? 0, replyCount: map['replyCount'] ?? 0, likeCount: map['likeCount'] ?? 0, isLiked: map['isLiked'] ?? false, isLocked: map['isLocked'] ?? false, isSubscribed: map['isSubscribed'] ?? false, isPinned: map['isSticky'] ?? false, siteUrl: map['siteUrl'] ?? '', createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']), categories: categories, media: media, userId: map['user']?['id'] ?? 0, userName: map['user']?['name'] ?? '?', userAvatarUrl: map['user']?['avatar']?['large'] ?? '', ); } final int id; final String title; final String body; final int viewCount; int likeCount; final int replyCount; bool isLiked; bool isSubscribed; final bool isPinned; final bool isLocked; final String siteUrl; final DateTime createdAt; final List categories; final List media; final int userId; final String userName; final String userAvatarUrl; } typedef ThreadMedia = ({int id, String title, String coverUrl}); ================================================ FILE: lib/feature/thread/thread_provider.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/feature/thread/thread_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; final threadProvider = AsyncNotifierProvider.autoDispose.family( ThreadNotifier.new, ); class ThreadNotifier extends AsyncNotifier { ThreadNotifier(this.arg); final int arg; @override FutureOr build() async { final data = await ref.read(repositoryProvider).request(GqlQuery.thread, { 'id': arg, 'withInfo': true, }); final options = ref.watch(persistenceProvider.select((s) => s.options)); return Thread(data, options.imageQuality); } Future changePage(int page) async { final value = state.value; if (value == null) return; state = state.whenData((data) => data.withChangingCommentPage(page)); state = const AsyncValue.loading(); final data = await ref.read(repositoryProvider).request(GqlQuery.thread, { 'id': arg, 'page': page, }); state = AsyncValue.data(value.withChangedCommentPage(data)); } void appendComment(Map map, int? parentCommentId) { final value = state.value; if (value == null) return; // If there's a new thread comment, it can only appear on the last page. if (parentCommentId == null && value.commentPage != value.totalCommentPages) { return; } state = AsyncValue.data(value.withAppendedComment(map, parentCommentId)); } Future toggleThreadLike() { final value = state.value; if (value == null) return Future.value(null); return ref.read(repositoryProvider).request(GqlMutation.toggleLike, { 'id': value.info.id, 'type': 'THREAD', }).getErrorOrNull(); } Future toggleCommentLike(int commentId) { return ref.read(repositoryProvider).request(GqlMutation.toggleLike, { 'id': commentId, 'type': 'THREAD_COMMENT', }).getErrorOrNull(); } Future toggleThreadSubscription() async { final value = state.value; if (value == null) return null; final info = value.info; final prevIsSubscribed = info.isSubscribed; info.isSubscribed = !prevIsSubscribed; final err = await ref.read(repositoryProvider).request(GqlMutation.toggleThreadSubscription, { 'id': info.id, 'subscribe': info.isSubscribed, }).getErrorOrNull(); if (err != null) { info.isSubscribed = prevIsSubscribed; return err; } return null; } Future delete() => ref.read(repositoryProvider).request(GqlMutation.deleteThread, {'id': arg}).getErrorOrNull(); } ================================================ FILE: lib/feature/thread/thread_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/feature/composition/composition_model.dart'; import 'package:otraku/feature/composition/composition_view.dart'; import 'package:otraku/feature/comment/comment_tile.dart'; import 'package:otraku/feature/forum/forum_filter_model.dart'; import 'package:otraku/feature/forum/forum_filter_provider.dart'; import 'package:otraku/feature/thread/thread_model.dart'; import 'package:otraku/feature/thread/thread_provider.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/navigation_tool.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/shadowed_overflow_list.dart'; import 'package:otraku/widget/sheets.dart'; import 'package:otraku/widget/timestamp.dart'; class ThreadView extends ConsumerStatefulWidget { const ThreadView(this.id); final int id; @override ConsumerState createState() => _ThreadViewState(); } class _ThreadViewState extends ConsumerState { final _scrollCtrl = ScrollController(); @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { ref.listen( threadProvider(widget.id), (_, s) => s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())), ); final thread = ref.watch(threadProvider(widget.id)); final options = ref.watch(persistenceProvider.select((s) => s.options)); final viewerId = ref.watch(viewerIdProvider); return AdaptiveScaffold( topBar: TopBar( trailing: thread.hasValue ? _topBarTrailingContent(thread.value!, viewerId) : const [], ), floatingAction: HidingFloatingActionButton( key: const Key('Reply'), scrollCtrl: _scrollCtrl, child: FloatingActionButton( tooltip: 'New Reply', child: const Icon(Icons.edit_outlined), onPressed: () => showSheet( context, CompositionView( tag: CommentCompositionTag(threadId: widget.id, parentCommentId: null), onSaved: (map) => ref.read(threadProvider(widget.id).notifier).appendComment(map, null), ), ), ), ), bottomBar: thread.hasValue && thread.value!.totalCommentPages > 1 ? _BottomBar( thread: thread.value!, changePage: (page) => ref.read(threadProvider(widget.id).notifier).changePage(page), ) : null, child: ConstrainedView( child: switch (thread.unwrapPrevious()) { AsyncData(:final value) => _Content( ref, value, options.highContrast, options.analogClock, _scrollCtrl, ), AsyncError() => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ SliverRefreshControl(onRefresh: () => ref.invalidate(threadProvider(widget.id))), const SliverFillRemaining(child: Center(child: Text('Failed to load'))), ], ), AsyncLoading() => const Center(child: Loader()), }, ), ); } List _topBarTrailingContent(Thread thread, int? viewerId) => [ Expanded( child: GestureDetector( behavior: .opaque, onTap: () => context.push(Routes.user(thread.info.userId, thread.info.userAvatarUrl)), child: Row( mainAxisSize: .min, children: [ Hero( tag: thread.info.userId, child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: CachedImage(thread.info.userAvatarUrl, height: 40, width: 40), ), ), const SizedBox(width: Theming.offset), Flexible(child: Text(thread.info.userName, overflow: .ellipsis, maxLines: 1)), ], ), ), ), IconButton( tooltip: 'More', icon: const Icon(Ionicons.ellipsis_horizontal), onPressed: () => showSheet( context, SimpleSheet.link(context, thread.info.siteUrl, [ ListTile( title: !thread.info.isSubscribed ? const Text('Subscribe') : const Text('Unsubscribe'), leading: !thread.info.isSubscribed ? const Icon(Ionicons.notifications_outline) : const Icon(Ionicons.notifications_off_outline), onTap: _toggleSubscription, ), if (viewerId == thread.info.userId) ListTile( title: const Text('Delete'), leading: const Icon(Ionicons.trash_outline), onTap: () { Navigator.pop(context); ConfirmationDialog.show( context, title: 'Delete?', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: _delete, ); }, ), ]), ), ), ]; void _toggleSubscription() async { final err = await ref.read(threadProvider(widget.id).notifier).toggleThreadSubscription(); if (!mounted) return; if (err == null) { Navigator.pop(context); return; } SnackBarExtension.show(context, err.toString()); Navigator.pop(context); } void _delete() async { final err = await ref.read(threadProvider(widget.id).notifier).delete(); if (!mounted) return; if (err == null) { Navigator.pop(context); return; } SnackBarExtension.show(context, 'Failed deleting thread: $err'); } } class _BottomBar extends StatefulWidget { const _BottomBar({required this.thread, required this.changePage}); final Thread thread; final void Function(int page) changePage; @override State<_BottomBar> createState() => __BottomBarState(); } class __BottomBarState extends State<_BottomBar> { late var _value = widget.thread.commentPage; @override void didUpdateWidget(covariant _BottomBar oldWidget) { super.didUpdateWidget(oldWidget); _value = widget.thread.commentPage; } @override Widget build(BuildContext context) { final thread = widget.thread; final currentPageLabel = Text('$_value'); final previousPageButton = IconButton( tooltip: 'Previous page', icon: const Icon(Icons.arrow_back_ios_rounded), onPressed: thread.commentPage == 1 ? null : () => widget.changePage(thread.commentPage - 1), ); final nextPageButton = IconButton( tooltip: 'Next page', icon: const Icon(Icons.arrow_forward_ios_rounded), onPressed: thread.commentPage == thread.totalCommentPages ? null : () => widget.changePage(thread.commentPage + 1), ); final pageSlider = Expanded( child: Slider.adaptive( min: 1, max: thread.totalCommentPages.toDouble(), value: _value.toDouble(), onChanged: (value) => setState(() => _value = value.round()), onChangeEnd: (value) => widget.changePage(value.round()), ), ); final bottomBarItems = Theming.of(context).rightButtonOrientation ? [pageSlider, previousPageButton, currentPageLabel, nextPageButton] : [previousPageButton, currentPageLabel, nextPageButton, pageSlider]; return BottomBar(bottomBarItems); } } class _Content extends StatelessWidget { const _Content(this.ref, this.thread, this.highContrast, this.analogClock, this.scrollCtrl); final WidgetRef ref; final Thread thread; final bool highContrast; final bool analogClock; final ScrollController scrollCtrl; @override Widget build(BuildContext context) { final viewerId = ref.watch(viewerIdProvider); const spacing = SliverToBoxAdapter(child: SizedBox(height: Theming.offset)); final info = thread.info; return CustomScrollView( controller: scrollCtrl, physics: Theming.bouncyPhysics, slivers: [ SliverRefreshControl(onRefresh: () => ref.invalidate(threadProvider(thread.info.id))), SliverToBoxAdapter(child: Timestamp(info.createdAt, analogClock)), spacing, SliverToBoxAdapter(child: Text(thread.info.title, style: TextTheme.of(context).bodyMedium)), spacing, HtmlContent(thread.info.body, renderMode: RenderMode.sliverList), spacing, if (info.media.isNotEmpty) SliverToBoxAdapter( child: SizedBox( height: Theming.minTapTarget, child: ShadowedOverflowList( itemCount: info.media.length, itemBuilder: (context, i) { final media = info.media[i]; return ActionChip( label: Text(media.title), avatar: CachedImage(media.coverUrl), onPressed: () => context.push(Routes.media(media.id)), ); }, ), ), ), SliverToBoxAdapter( child: SizedBox( height: Theming.minTapTarget, child: ShadowedOverflowList( itemCount: info.categories.length, itemBuilder: (context, i) { final label = info.categories[i]; return ActionChip( label: Text(label), onPressed: () { context.push(Routes.forum); ref.invalidate(forumFilterProvider); ref .read(forumFilterProvider.notifier) .update( (filter) => filter.copyWith(category: (ThreadCategory.from(label),)), ); }, ); }, ), ), ), SliverToBoxAdapter( child: Padding( padding: const .symmetric(vertical: Theming.offset), child: Row( spacing: Theming.offset, children: [ if (info.isPinned) Tooltip( message: 'Pinned', triggerMode: .tap, child: Icon(Icons.push_pin_outlined, size: Theming.iconSmall), ), if (info.isLocked) Tooltip( message: 'Locked', triggerMode: .tap, child: Icon(Icons.lock_outline_rounded, size: Theming.iconSmall), ), const Spacer(), Tooltip( message: 'Views', triggerMode: .tap, child: Row( mainAxisSize: .min, children: [ Text( info.viewCount.toString(), style: Theme.of(context).textTheme.labelSmall, ), const SizedBox(width: 5), Icon(Icons.remove_red_eye_outlined, size: Theming.iconSmall), ], ), ), Tooltip( message: 'Replies', triggerMode: .tap, child: Row( mainAxisSize: .min, spacing: 5, children: [ Text( info.replyCount.toString(), style: Theme.of(context).textTheme.labelSmall, ), Icon(Icons.reply_all_rounded, size: Theming.iconSmall), ], ), ), _LikeButton(ref, info), ], ), ), ), spacing, SliverList.builder( itemCount: thread.comments.length, itemBuilder: (context, i) { final comment = thread.comments[i]; return Padding( padding: const .only(bottom: Theming.offset), child: CommentTile( comment, viewerId: viewerId, highContrast: highContrast, analogClock: analogClock, interaction: ( onReplySaved: (map, commentId) => ref.read(threadProvider(info.id).notifier).appendComment(map, commentId), toggleLike: (commentId) => ref.read(threadProvider(info.id).notifier).toggleCommentLike(commentId), ), ), ); }, ), const SliverFooter(), ], ); } } class _LikeButton extends StatefulWidget { const _LikeButton(this.ref, this.threadInfo); final WidgetRef ref; final ThreadInfo threadInfo; @override State<_LikeButton> createState() => __LikeButtonState(); } class __LikeButtonState extends State<_LikeButton> { @override Widget build(BuildContext context) { final info = widget.threadInfo; return Tooltip( message: !info.isLiked ? 'Like' : 'Unlike', child: InkResponse( radius: Theming.radiusSmall.x, onTap: () async { final prevIsLiked = info.isLiked; final prevLikeCount = info.likeCount; setState(() { info.isLiked = !prevIsLiked; info.likeCount = prevLikeCount + 1; }); final err = await widget.ref.read(threadProvider(info.id).notifier).toggleThreadLike(); if (err == null) return; setState(() { info.isLiked = prevIsLiked; info.likeCount = prevLikeCount; }); if (context.mounted) { SnackBarExtension.show(context, err.toString()); } }, child: Row( children: [ Text( info.likeCount.toString(), style: !info.isLiked ? TextTheme.of(context).labelSmall : TextTheme.of( context, ).labelSmall!.copyWith(color: ColorScheme.of(context).primary), ), const SizedBox(width: 5), Icon( !info.isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded, size: Theming.iconSmall, color: info.isLiked ? ColorScheme.of(context).primary : null, ), ], ), ), ); } } ================================================ FILE: lib/feature/user/user_header.dart ================================================ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/feature/user/user_model.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/input/pill_selector.dart'; import 'package:otraku/widget/layout/content_header.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/text_rail.dart'; class UserHeader extends StatelessWidget { const UserHeader({ required this.id, required this.isViewer, required this.user, required this.imageUrl, required this.toggleFollow, }); final int? id; final bool isViewer; final User? user; final String? imageUrl; final Future Function() toggleFollow; @override Widget build(BuildContext context) { final textRailItems = {}; if (user != null) { if (user!.modRoles.isNotEmpty) textRailItems[user!.modRoles[0]] = false; if (user!.donatorTier > 0) textRailItems[user!.donatorBadge] = true; } return ContentHeader( imageUrl: user?.imageUrl ?? imageUrl, imageHeightToWidthRatio: 1, imageHeroTag: id ?? '', imageFit: BoxFit.contain, bannerUrl: user?.bannerUrl, siteUrl: user?.siteUrl, title: user?.name, details: [ GestureDetector( behavior: .opaque, onTap: () { if (user?.modRoles.isNotEmpty ?? false) { showDialog( context: context, builder: (context) => TextDialog(title: 'Roles', text: user!.modRoles.join(', ')), ); } }, child: TextRail(textRailItems, style: TextTheme.of(context).labelMedium), ), if (user?.createdAt != null) Text('Joined ${user!.createdAt!}', style: TextTheme.of(context).labelSmall), ], trailingTopButtons: [ if (isViewer) ...[ IconButton( tooltip: 'Switch Account', icon: const Icon(Icons.manage_accounts_outlined), onPressed: () => showDialog(context: context, builder: (context) => const _AccountPicker()), ), IconButton( tooltip: 'Settings', icon: const Icon(Ionicons.cog_outline), onPressed: () => context.push(Routes.settings), ), ] else if (user != null) _FollowButton(user!, toggleFollow), ], ); } } class _AccountPicker extends StatefulWidget { const _AccountPicker(); @override State<_AccountPicker> createState() => __AccountPickerState(); } class __AccountPickerState extends State<_AccountPicker> { static const _loginLink = 'https://anilist.co/api/v2/oauth/authorize?client_id=3535&response_type=token'; static const _imageSize = 55.0; @override Widget build(BuildContext context) { const divider = SizedBox(height: 40, child: VerticalDivider(width: 10, thickness: 1)); final bodyMediumTextHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final labelSmallTextHeight = context.lineHeight(TextTheme.of(context).labelSmall!); final rowHeight = max(_imageSize, bodyMediumTextHeight + labelSmallTextHeight * 2) + 10; return Dialog( insetPadding: const .symmetric(vertical: 24, horizontal: Theming.offset), shape: const RoundedRectangleBorder( borderRadius: BorderRadiusGeometry.all(Radius.circular(32)), ), child: Consumer( builder: (context, ref, _) { final accountGroup = ref.watch(persistenceProvider.select((s) => s.accountGroup)); final accounts = accountGroup.accounts; final items = [ for (int i = 0; i < accounts.length; i++) SizedBox( height: rowHeight, child: Row( children: [ Padding( padding: .all(5), child: CachedImage( accounts[i].avatarUrl, width: _imageSize, height: _imageSize, ), ), Expanded( child: Column( mainAxisAlignment: .center, crossAxisAlignment: .start, children: [ Text( '${accounts[i].name} ${accounts[i].id}', overflow: .ellipsis, maxLines: 1, ), Text( DateTime.now().isBefore(accounts[i].expiration) ? 'Expires in ${accounts[i].expiration.timeUntil}' : 'Expired', style: TextTheme.of(context).labelSmall, overflow: .ellipsis, maxLines: 2, ), ], ), ), divider, IconButton( tooltip: 'Remove Account', icon: const Icon(Icons.close_rounded), onPressed: () => ConfirmationDialog.show( context, title: 'Remove Account?', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: () { if (i == accountGroup.accountIndex) { ref.read(persistenceProvider.notifier).switchAccount(null); } ref .read(persistenceProvider.notifier) .removeAccount(i) .then((_) => setState(() {})); }, ), ), ], ), ), ]; items.add( SizedBox( height: rowHeight, child: Row( children: [ const Padding( padding: .all(5), child: Icon(Icons.person_rounded, size: _imageSize), ), const Expanded(child: Text('Guest')), divider, IconButton( tooltip: 'Add Account', icon: const Icon(Icons.add_rounded), onPressed: () => _addAccount(accounts.isEmpty), ), ], ), ), ); return PillSelector( maxWidth: 380, shrinkWrap: true, selected: accountGroup.accountIndex ?? accounts.length, items: items, onTap: (i) async { if (i == accounts.length) { ref.read(persistenceProvider.notifier).switchAccount(null); Navigator.pop(context); return; } if (DateTime.now().isBefore(accounts[i].expiration)) { ref.read(persistenceProvider.notifier).switchAccount(i); Navigator.pop(context); return; } var ok = false; await ConfirmationDialog.show( context, title: 'Session expired', content: 'Do you want to log in again?', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: () => ok = true, ); if (ok) _addAccount(accounts.isEmpty); }, ); }, ), ); } void _addAccount(bool isAccountListEmpty) { if (isAccountListEmpty) { SnackBarExtension.launch(context, _loginLink); return; } ConfirmationDialog.show( context, title: 'Add an Account', content: 'To add more accounts, make sure you\'re logged out of the previous ones in the browser.', primaryAction: 'Continue', secondaryAction: 'Cancel', onConfirm: () { if (mounted) { SnackBarExtension.launch(context, _loginLink); } }, ); } } class _FollowButton extends StatefulWidget { const _FollowButton(this.user, this.toggleFollow); final User user; final Future Function() toggleFollow; @override State<_FollowButton> createState() => __FollowButtonState(); } class __FollowButtonState extends State<_FollowButton> { @override Widget build(BuildContext context) { final user = widget.user; return Padding( padding: const .all(Theming.offset), child: ElevatedButton.icon( icon: Icon( user.isFollowed ? Ionicons.person_remove_outline : Ionicons.person_add_outline, size: Theming.iconSmall, ), label: Text( user.isFollowed ? user.isFollower ? 'Mutual' : 'Following' : user.isFollower ? 'Follower' : 'Follow', ), onPressed: () { final isFollowed = user.isFollowed; setState(() => user.isFollowed = !isFollowed); widget.toggleFollow().then((err) { if (err == null) return; setState(() => user.isFollowed = isFollowed); if (context.mounted) { SnackBarExtension.show(context, err.toString()); } }); }, ), ); } } ================================================ FILE: lib/feature/user/user_item_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/user/user_item_model.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; class UserItemGrid extends StatelessWidget { const UserItemGrid(this.items, {required this.highContrast}); final List items; final bool highContrast; @override Widget build(BuildContext context) { final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!); final textHeight = lineHeight * 2 + 10; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight( minWidth: 100, extraHeight: textHeight, ), delegate: SliverChildBuilderDelegate( (_, i) => _Tile(items[i], highContrast, textHeight), childCount: items.length, ), ); } } class _Tile extends StatelessWidget { const _Tile(this.item, this.highContrast, this.textHeight); final UserItem item; final bool highContrast; final double textHeight; @override Widget build(BuildContext context) { return InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => context.push(Routes.user(item.id, item.imageUrl)), child: CardExtension.highContrast(highContrast)( child: Column( spacing: 5, children: [ Expanded( child: Hero( tag: item.id, child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall), child: CachedImage(item.imageUrl), ), ), ), SizedBox( height: textHeight, child: Padding( padding: const .all(5), child: Text(item.name, maxLines: 2, overflow: .ellipsis), ), ), ], ), ), ); } } ================================================ FILE: lib/feature/user/user_item_model.dart ================================================ class UserItem { const UserItem._({required this.id, required this.name, required this.imageUrl}); factory UserItem(Map map) => UserItem._(id: map['id'], name: map['name'], imageUrl: map['avatar']['large']); final int id; final String name; final String imageUrl; } ================================================ FILE: lib/feature/user/user_model.dart ================================================ import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/extension/string_extension.dart'; import 'package:otraku/util/markdown.dart'; import 'package:otraku/feature/statistics/statistics_model.dart'; class User { User._({ required this.id, required this.name, required this.createdAt, required this.description, required this.imageUrl, required this.bannerUrl, required this.siteUrl, required this.isFollowed, required this.isFollower, required this.isBlocked, required this.donatorTier, required this.donatorBadge, required this.modRoles, required this.animeStats, required this.mangaStats, }); factory User(Map map) { final modRoles = []; if (map['moderatorRoles'] != null) { for (String r in map['moderatorRoles']) { modRoles.add(r.noScreamingSnakeCase); } } return User._( id: map['id'], name: map['name'], createdAt: map['createdAt'] != null ? DateTime.fromMillisecondsSinceEpoch(map['createdAt'] * 1000).formattedDate : null, description: parseMarkdown(map['about'] ?? ''), imageUrl: map['avatar']['large'], bannerUrl: map['bannerImage'], siteUrl: map['siteUrl'], isFollowed: map['isFollowing'] ?? false, isFollower: map['isFollower'] ?? false, isBlocked: map['isBlocked'] ?? false, donatorTier: map['donatorTier'] ?? 0, donatorBadge: map['donatorBadge'] ?? '', modRoles: modRoles, animeStats: Statistics(map['statistics']['anime'], true), mangaStats: Statistics(map['statistics']['manga'], false), ); } final int id; final String name; final String? createdAt; final String description; final String imageUrl; final String? bannerUrl; final String? siteUrl; bool isFollowed; final bool isFollower; final bool isBlocked; final int donatorTier; final String donatorBadge; final List modRoles; final Statistics animeStats; final Statistics mangaStats; } ================================================ FILE: lib/feature/user/user_providers.dart ================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/extension/future_extension.dart'; import 'package:otraku/feature/user/user_model.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/graphql.dart'; typedef UserTag = ({int? id, String? name}); UserTag idUserTag(int id) => (id: id, name: null); UserTag nameUserTag(String name) => (id: null, name: name); final userProvider = AsyncNotifierProvider.autoDispose.family( UserNotifier.new, ); class UserNotifier extends AsyncNotifier { UserNotifier(this.arg); final UserTag arg; @override FutureOr build() async { final data = await ref .read(repositoryProvider) .request(GqlQuery.user, arg.id != null ? {'id': arg.id} : {'name': arg.name}); return User(data['User']); } Future toggleFollow(int userId) { return ref.read(repositoryProvider).request(GqlMutation.toggleFollow, { 'userId': userId, }).getErrorOrNull(); } } ================================================ FILE: lib/feature/user/user_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; import 'package:otraku/feature/user/user_model.dart'; import 'package:otraku/feature/user/user_providers.dart'; import 'package:otraku/feature/user/user_header.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/loaders.dart'; class UserView extends StatelessWidget { const UserView(this.tag, this.avatarUrl); final UserTag tag; final String? avatarUrl; @override Widget build(BuildContext context) => AdaptiveScaffold(child: _UserView(tag, avatarUrl)); } /// The home page has app bars, /// but the one on the user tab should be transparent /// and the padding should be removed. class UserHomeView extends StatelessWidget { const UserHomeView( this.tag, this.avatarUrl, { this.homeScrollCtrl, required this.removableTopPadding, }); final UserTag? tag; final String? avatarUrl; final ScrollController? homeScrollCtrl; final double removableTopPadding; @override Widget build(BuildContext context) { final body = tag != null ? _UserView(tag!, avatarUrl, homeScrollCtrl) : CustomScrollView( controller: homeScrollCtrl, physics: Theming.bouncyPhysics, slivers: [ UserHeader( id: null, user: null, isViewer: true, imageUrl: null, toggleFollow: () async => null, ), const SliverToBoxAdapter( child: Center( child: Padding( padding: Theming.paddingAll, child: Text( 'Log in with the profile icon at the top to view your account', textAlign: .center, ), ), ), ), const SliverFooter(), ], ); final mediaQuery = MediaQuery.of(context); return MediaQuery( data: mediaQuery.copyWith( padding: mediaQuery.padding.copyWith(top: mediaQuery.padding.top - removableTopPadding), ), child: body, ); } } class _UserView extends StatelessWidget { const _UserView(this.tag, this.avatarUrl, [this.scrollCtrl]); final UserTag tag; final String? avatarUrl; final ScrollController? scrollCtrl; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { final persistence = ref.watch(persistenceProvider); final highContrast = persistence.options.highContrast; final viewer = persistence.accountGroup.account; final isViewer = viewer != null && (tag.id != null ? tag.id == viewer.id : tag.name == viewer.name); ref.listen>( userProvider(tag), (_, s) => s.whenOrNull( data: (data) { if (!isViewer) return; ref.read(persistenceProvider.notifier).refreshViewerDetails(data.name, data.imageUrl); }, error: (error, _) => SnackBarExtension.show(context, error.toString()), ), ); final user = ref.watch(userProvider(tag)); final header = UserHeader( id: tag.id, user: user.value, isViewer: isViewer, imageUrl: avatarUrl ?? user.value?.imageUrl, toggleFollow: () { final userId = user.value?.id; if (userId == null) return Future.value(false); return ref.read(userProvider(tag).notifier).toggleFollow(userId); }, ); final mediaQuery = MediaQuery.of(context); final refreshControl = MediaQuery( data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)), child: SliverRefreshControl(onRefresh: () => ref.invalidate(userProvider(tag))), ); return user.unwrapPrevious().when( error: (_, _) => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ header, refreshControl, const SliverFillRemaining(child: Center(child: Text('Failed to load user'))), ], ), loading: () => CustomScrollView( slivers: [ header, const SliverFillRemaining(child: Center(child: Loader())), ], ), data: (data) => CustomScrollView( controller: scrollCtrl, physics: Theming.bouncyPhysics, slivers: [ header, refreshControl, _ButtonRow(data.id, isViewer, highContrast), if (data.description.isNotEmpty) ...[ const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)), SliverConstrainedView( sliver: HtmlContent(data.description, renderMode: RenderMode.sliverList), ), ], const SliverFooter(), ], ), ); }, ); } } class _ButtonRow extends StatelessWidget { const _ButtonRow(this.userId, this.isViewer, this.highContrast); final int userId; final bool isViewer; final bool highContrast; @override Widget build(BuildContext context) { final buttonHeight = Theming.iconBig + context.lineHeight(TextTheme.of(context).bodyMedium!) + Theming.offset * 2.5; final buttons = [ _Button( label: 'Anime', icon: Ionicons.film, highContrast: highContrast, onTap: () => isViewer ? context.go(Routes.home(.anime)) : context.push(Routes.animeCollection(userId)), ), _Button( label: 'Manga', icon: Ionicons.book, highContrast: highContrast, onTap: () => isViewer ? context.go(Routes.home(.manga)) : context.push(Routes.mangaCollection(userId)), ), _Button( label: 'Activities', icon: Ionicons.chatbox, highContrast: highContrast, onTap: () => context.push(Routes.activities(userId)), ), _Button( label: 'Social', icon: Ionicons.people_circle, highContrast: highContrast, onTap: () => context.push(Routes.social(userId)), ), _Button( label: 'Favourites', icon: Icons.favorite, highContrast: highContrast, onTap: () => context.push(Routes.favorites(userId)), ), _Button( label: 'Statistics', icon: Ionicons.stats_chart, highContrast: highContrast, onTap: () => context.push(Routes.statistics(userId)), ), _Button( label: 'Reviews', icon: Icons.rate_review, highContrast: highContrast, onTap: () => context.push(Routes.reviews(userId)), ), ]; return SliverPadding( padding: const .symmetric(horizontal: Theming.offset, vertical: Theming.offset), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: Theming.offset, crossAxisSpacing: Theming.offset, mainAxisExtent: buttonHeight, ), delegate: SliverChildBuilderDelegate( (context, i) => buttons[i], childCount: buttons.length, ), ), ); } } class _Button extends StatelessWidget { const _Button({ required this.label, required this.icon, required this.highContrast, required this.onTap, }); final String label; final IconData icon; final bool highContrast; final void Function() onTap; @override Widget build(BuildContext context) { return CardExtension.highContrast(highContrast)( child: InkWell( onTap: onTap, borderRadius: Theming.borderRadiusSmall, child: Padding( padding: Theming.paddingAll, child: Column(mainAxisAlignment: .spaceBetween, children: [Icon(icon), Text(label)]), ), ), ); } } ================================================ FILE: lib/feature/viewer/persistence_model.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/enum_extension.dart'; import 'package:otraku/feature/activity/activities_filter_model.dart'; import 'package:otraku/feature/calendar/calendar_models.dart'; import 'package:otraku/feature/collection/collection_filter_model.dart'; import 'package:otraku/feature/collection/collection_models.dart'; import 'package:otraku/feature/discover/discover_filter_model.dart'; import 'package:otraku/feature/discover/discover_model.dart'; import 'package:otraku/feature/home/home_model.dart'; import 'package:otraku/util/theming.dart'; const appVersion = '1.12.1'; class Persistence { const Persistence({ required this.systemColors, required this.accountGroup, required this.options, required this.appMeta, required this.animeCollectionMediaFilter, required this.mangaCollectionMediaFilter, required this.discoverMediaFilter, required this.homeActivitiesFilter, required this.mediaActivitiesFilter, required this.calendarFilter, }); factory Persistence.empty() => Persistence( systemColors: (lightPrimaryColor: null, darkPrimaryColor: null), accountGroup: .empty(), options: .empty(), appMeta: .empty(), animeCollectionMediaFilter: CollectionMediaFilter(), mangaCollectionMediaFilter: CollectionMediaFilter(), discoverMediaFilter: DiscoverMediaFilter(.titleRomaji), homeActivitiesFilter: .empty(), mediaActivitiesFilter: .empty(), calendarFilter: .empty(), ); factory Persistence.fromPersistenceMap( Map map, Map accessTokens, ) { final accountGroup = AccountGroup.fromPersistenceMap( map['accountGroup'] ?? const {}, accessTokens, ); return Persistence( systemColors: (lightPrimaryColor: null, darkPrimaryColor: null), accountGroup: accountGroup, options: .fromPersistenceMap(map['options'] ?? const {}), appMeta: .fromPersistenceMap(map['appMeta'] ?? const {}), animeCollectionMediaFilter: .fromPersistenceMap( map['animeCollectionMediaFilter'] ?? const {}, ), mangaCollectionMediaFilter: .fromPersistenceMap( map['mangaCollectionMediaFilter'] ?? const {}, ), discoverMediaFilter: .fromPersistenceMap(map['discoverMediaFilter'] ?? const {}), homeActivitiesFilter: .fromPersistenceMap( map['homeActivitiesFilter'] ?? const {}, accountGroup.account?.id, ), mediaActivitiesFilter: .fromPersistence( map['mediaActivitiesFilter'] ?? const {}, 0, accountGroup.account?.id, ), calendarFilter: .fromPersistenceMap(map['calendarFilter'] ?? const {}), ); } final SystemColors systemColors; final AccountGroup accountGroup; final Options options; final AppMeta appMeta; final CollectionMediaFilter animeCollectionMediaFilter; final CollectionMediaFilter mangaCollectionMediaFilter; final DiscoverMediaFilter discoverMediaFilter; final HomeActivitiesFilter homeActivitiesFilter; final MediaActivitiesFilter mediaActivitiesFilter; final CalendarFilter calendarFilter; Persistence copyWith({ SystemColors? systemColors, AccountGroup? accountGroup, Options? options, AppMeta? appMeta, CollectionMediaFilter? animeCollectionMediaFilter, CollectionMediaFilter? mangaCollectionMediaFilter, DiscoverMediaFilter? discoverMediaFilter, HomeActivitiesFilter? homeActivitiesFilter, CalendarFilter? calendarFilter, MediaActivitiesFilter? mediaActivitiesFilter, }) => Persistence( systemColors: systemColors ?? this.systemColors, accountGroup: accountGroup ?? this.accountGroup, options: options ?? this.options, appMeta: appMeta ?? this.appMeta, animeCollectionMediaFilter: animeCollectionMediaFilter ?? this.animeCollectionMediaFilter, mangaCollectionMediaFilter: mangaCollectionMediaFilter ?? this.mangaCollectionMediaFilter, discoverMediaFilter: discoverMediaFilter ?? this.discoverMediaFilter, homeActivitiesFilter: homeActivitiesFilter ?? this.homeActivitiesFilter, calendarFilter: calendarFilter ?? this.calendarFilter, mediaActivitiesFilter: mediaActivitiesFilter ?? this.mediaActivitiesFilter, ); } typedef SystemColors = ({Color? lightPrimaryColor, Color? darkPrimaryColor}); class AccountGroup { const AccountGroup({required this.accounts, required this.accountIndex}); factory AccountGroup.empty() => const AccountGroup(accounts: [], accountIndex: null); factory AccountGroup.fromPersistenceMap( Map map, Map accessTokens, ) { final accounts = []; for (final a in map['accounts'] ?? const []) { final accessToken = accessTokens[Account.accessTokenKeyById(a['id'])]; if (accessToken == null) continue; accounts.add(.fromPersistenceMap(a, accessToken)); } int? accountIndex = map['accountIndex']?.clamp(0, accounts.length - 1); // Can't use an account whose token has expired. if (accountIndex != null && accounts[accountIndex].expiration.compareTo(DateTime.now()) <= 0) { accountIndex = null; } return AccountGroup(accounts: accounts, accountIndex: accountIndex); } final List accounts; final int? accountIndex; Account? get account => accountIndex != null ? accounts[accountIndex!] : null; Map toPersistenceMap() => { 'accounts': accounts.map((a) => a.toPersistenceMap()).toList(), 'accountIndex': accountIndex, }; } class Account { const Account({ required this.id, required this.name, required this.avatarUrl, required this.expiration, required this.accessToken, }); factory Account.fromPersistenceMap(Map map, String accessToken) => Account( id: map['id'], name: map['name'], avatarUrl: map['avatarUrl'], expiration: map['expiration'], accessToken: accessToken, ); final int id; final String name; final String avatarUrl; final DateTime expiration; final String accessToken; static String accessTokenKeyById(int id) => 'auth$id'; Map toPersistenceMap() => { 'id': id, 'name': name, 'avatarUrl': avatarUrl, 'expiration': expiration, }; } class Options { const Options({ required this.themeMode, required this.themeBase, required this.highContrast, required this.homeTab, required this.discoverType, required this.imageQuality, required this.animeCollectionPreview, required this.mangaCollectionPreview, required this.confirmExit, required this.analogClock, required this.buttonOrientation, required this.discoverItemView, required this.collectionItemView, required this.collectionPreviewItemView, }); factory Options.empty() => const Options( themeMode: ThemeMode.system, themeBase: null, highContrast: false, homeTab: .feed, discoverType: .anime, imageQuality: .high, animeCollectionPreview: true, mangaCollectionPreview: true, confirmExit: false, analogClock: false, buttonOrientation: .auto, discoverItemView: .detailed, collectionItemView: .detailed, collectionPreviewItemView: .detailed, ); factory Options.fromPersistenceMap(Map map) => Options( themeMode: ThemeMode.values.getOrFirst(map['themeMode']), themeBase: ThemeBase.values.getOrNull(map['themeBase']), highContrast: map['highContrast'] ?? false, homeTab: HomeTab.values.getOrFirst(map['homeTab']), discoverType: DiscoverType.values.getOrFirst(map['discoverType']), imageQuality: ImageQuality.values.getOrNull(map['imageQuality']) ?? .high, animeCollectionPreview: map['animeCollectionPreview'] ?? true, mangaCollectionPreview: map['mangaCollectionPreview'] ?? true, confirmExit: map['confirmExit'] ?? false, buttonOrientation: ButtonOrientation.values.getOrFirst(map['buttonOrientation']), analogClock: map['analogClock'] ?? false, discoverItemView: DiscoverItemView.values.getOrFirst(map['discoverItemView']), collectionItemView: .values.getOrFirst(map['collectionItemView']), collectionPreviewItemView: .values.getOrFirst(map['collectionPreviewItemView']), ); final ThemeMode themeMode; final ThemeBase? themeBase; final bool highContrast; final HomeTab homeTab; final DiscoverType discoverType; final ImageQuality imageQuality; final bool animeCollectionPreview; final bool mangaCollectionPreview; final bool confirmExit; final bool analogClock; final ButtonOrientation buttonOrientation; final DiscoverItemView discoverItemView; final CollectionItemView collectionItemView; final CollectionItemView collectionPreviewItemView; Options copyWith({ ThemeMode? themeMode, (ThemeBase?,)? themeBase, bool? highContrast, HomeTab? homeTab, DiscoverType? discoverType, ImageQuality? imageQuality, bool? animeCollectionPreview, bool? mangaCollectionPreview, bool? confirmExit, bool? analogClock, ButtonOrientation? buttonOrientation, DiscoverItemView? discoverItemView, CollectionItemView? collectionItemView, CollectionItemView? collectionPreviewItemView, }) => Options( themeMode: themeMode ?? this.themeMode, themeBase: themeBase == null ? this.themeBase : themeBase.$1, highContrast: highContrast ?? this.highContrast, homeTab: homeTab ?? this.homeTab, discoverType: discoverType ?? this.discoverType, imageQuality: imageQuality ?? this.imageQuality, animeCollectionPreview: animeCollectionPreview ?? this.animeCollectionPreview, mangaCollectionPreview: mangaCollectionPreview ?? this.mangaCollectionPreview, confirmExit: confirmExit ?? this.confirmExit, buttonOrientation: buttonOrientation ?? this.buttonOrientation, analogClock: analogClock ?? this.analogClock, discoverItemView: discoverItemView ?? this.discoverItemView, collectionItemView: collectionItemView ?? this.collectionItemView, collectionPreviewItemView: collectionPreviewItemView ?? this.collectionPreviewItemView, ); Map toPersistenceMap() => { 'themeMode': themeMode.index, 'themeBase': themeBase?.index, 'highContrast': highContrast, 'homeTab': homeTab.index, 'discoverType': discoverType.index, 'imageQuality': imageQuality.index, 'animeCollectionPreview': animeCollectionPreview, 'mangaCollectionPreview': mangaCollectionPreview, 'confirmExit': confirmExit, 'analogClock': analogClock, 'buttonOrientation': buttonOrientation.index, 'discoverItemView': discoverItemView.index, 'collectionItemView': collectionItemView.index, 'collectionPreviewItemView': collectionPreviewItemView.index, }; } enum ImageQuality { veryHigh('Very High', 'extraLarge'), high('High', 'large'), medium('Medium', 'medium'); const ImageQuality(this.label, this.value); final String label; final String value; // Character and staff images don't have an "extra large" option. String get personValue => switch (this) { .veryHigh => ImageQuality.high.value, _ => value, }; } enum ButtonOrientation { auto, left, right } class AppMeta { const AppMeta({ required this.lastNotificationId, required this.lastAppVersion, required this.lastBackgroundJob, }); factory AppMeta.empty() => const AppMeta(lastNotificationId: -1, lastAppVersion: '', lastBackgroundJob: null); factory AppMeta.fromPersistenceMap(Map map) => AppMeta( lastNotificationId: map['lastNotificationId'] ?? -1, lastAppVersion: map['lastAppVersion'] ?? '', lastBackgroundJob: map['lastBackgroundJob'], ); final int lastNotificationId; final String lastAppVersion; final DateTime? lastBackgroundJob; Map toPersistenceMap() => { 'lastNotificationId': lastNotificationId, 'lastAppVersion': lastAppVersion, 'lastBackgroundJob': lastBackgroundJob, }; } ================================================ FILE: lib/feature/viewer/persistence_provider.dart ================================================ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive/hive.dart'; import 'package:otraku/feature/activity/activities_filter_model.dart'; import 'package:otraku/feature/calendar/calendar_models.dart'; import 'package:otraku/feature/collection/collection_filter_model.dart'; import 'package:otraku/feature/discover/discover_filter_model.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/util/background_handler.dart'; import 'package:path_provider/path_provider.dart'; final persistenceProvider = NotifierProvider( PersistenceNotifier.new, ); final viewerIdProvider = persistenceProvider.select((s) => s.accountGroup.account?.id); class PersistenceNotifier extends Notifier { late Box> _box; @override Persistence build() => .empty(); Future init() async { WidgetsFlutterBinding.ensureInitialized(); // Configure home directory, if not in the browser. if (!kIsWeb) Hive.init((await getApplicationDocumentsDirectory()).path); _box = await Hive.openBox('persistence'); final accessTokens = await const FlutterSecureStorage().readAll(); state = .fromPersistenceMap(_box.toMap(), accessTokens); } void cacheSystemPrimaryColors(SystemColors systemColors) { state = state.copyWith(systemColors: systemColors); } void setOptions(Options options) { _box.put('options', options.toPersistenceMap()); state = state.copyWith(options: options); } void setAppMeta(AppMeta appMeta) { _box.put('appMeta', appMeta.toPersistenceMap()); state = state.copyWith(appMeta: appMeta); } void setAnimeCollectionMediaFilter(CollectionMediaFilter mediaFilter) { _box.put('animeCollectionMediaFilter', mediaFilter.toPersistenceMap()); state = state.copyWith(animeCollectionMediaFilter: mediaFilter); } void setMangaCollectionMediaFilter(CollectionMediaFilter mediaFilter) { _box.put('mangaCollectionMediaFilter', mediaFilter.toPersistenceMap()); state = state.copyWith(mangaCollectionMediaFilter: mediaFilter); } void setDiscoverMediaFilter(DiscoverMediaFilter discoverMediaFilter) { _box.put('discoverMediaFilter', discoverMediaFilter.toPersistenceMap()); state = state.copyWith(discoverMediaFilter: discoverMediaFilter); } void setHomeActivitiesFilter(HomeActivitiesFilter homeActivitiesFilter) { _box.put('homeActivitiesFilter', homeActivitiesFilter.toPersistenceMap()); state = state.copyWith(homeActivitiesFilter: homeActivitiesFilter); } void setMediaActivitiesFilter(MediaActivitiesFilter mediaActivitiesFilter) { _box.put('mediaActivitiesFilter', mediaActivitiesFilter.toPersistenceMap()); state = state.copyWith(mediaActivitiesFilter: mediaActivitiesFilter); } void setCalendarFilter(CalendarFilter calendarFilter) { _box.put('calendarFilter', calendarFilter.toPersistenceMap()); state = state.copyWith(calendarFilter: calendarFilter); } void refreshViewerDetails(String newName, String newAvatarUrl) { final accounts = state.accountGroup.accounts; final accountIndex = state.accountGroup.accountIndex; if (accountIndex == null) return; final account = accounts[accountIndex]; if (account.name == newName && account.avatarUrl == newAvatarUrl) return; _setAccountGroup( AccountGroup( accounts: [ ...accounts.sublist(0, accountIndex), Account( name: newName, avatarUrl: newAvatarUrl, id: account.id, expiration: account.expiration, accessToken: account.accessToken, ), ...accounts.sublist(accountIndex + 1), ], accountIndex: accountIndex, ), ); } /// Switches active account. /// Don't switch to an account whose token has expired. void switchAccount(int? index) { final accountGroup = state.accountGroup; if (index == accountGroup.accountIndex) return; if (index != null && (index < 0 || index >= accountGroup.accounts.length)) { return; } if (index == null) BackgroundHandler.clearNotifications(); _setAccountGroup(AccountGroup(accountIndex: index, accounts: accountGroup.accounts)); } Future addAccount(Account account) async { final accounts = state.accountGroup.accounts; final accountIndex = state.accountGroup.accountIndex; await const FlutterSecureStorage().write( key: Account.accessTokenKeyById(account.id), value: account.accessToken, ); for (int i = 0; i < accounts.length; i++) { if (accounts[i].id == account.id) { _setAccountGroup( AccountGroup( accounts: [...accounts.sublist(0, i), account, ...accounts.sublist(i + 1)], accountIndex: accountIndex, ), ); switchAccount(i); return; } } _setAccountGroup(AccountGroup(accounts: [...accounts, account], accountIndex: accountIndex)); switchAccount(state.accountGroup.accounts.length - 1); } Future removeAccount(int index) async { final accountGroup = state.accountGroup; if (index == accountGroup.accountIndex) return; if (index < 0 || index >= accountGroup.accounts.length) return; final account = accountGroup.accounts[index]; await const FlutterSecureStorage().delete(key: Account.accessTokenKeyById(account.id)); _setAccountGroup( AccountGroup( accounts: [ ...accountGroup.accounts.sublist(0, index), ...accountGroup.accounts.sublist(index + 1), ], accountIndex: accountGroup.accountIndex, ), ); } /// Persists the account changes, but doesn't affect secure storage. /// Token changes must be handled separately. void _setAccountGroup(AccountGroup accountGroup) { _box.put('accountGroup', accountGroup.toPersistenceMap()); state = state.copyWith(accountGroup: accountGroup); } } ================================================ FILE: lib/feature/viewer/repository_model.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart'; class Repository { static final _url = Uri.parse('https://graphql.anilist.co'); Repository(String? accessToken) : _headers = { 'Accept': 'application/json', 'Content-type': 'application/json', if (accessToken != null) 'Authorization': 'Bearer $accessToken', }; final Map _headers; Future> request( String query, [ Map variables = const {}, ]) async { try { final response = await post( _url, body: json.encode({'query': query, 'variables': variables}), headers: _headers, ).timeout(const Duration(seconds: 30)); final Map body = json.decode(response.body); if (body.containsKey('errors')) { throw StateError((body['errors'] as List).map((e) => e['message'].toString()).join(', ')); } return body['data']; } on SocketException { throw Exception('Failed to connect'); } on TimeoutException { throw Exception('Request took too long'); } } } ================================================ FILE: lib/feature/viewer/repository_provider.dart ================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_model.dart'; final repositoryProvider = NotifierProvider(RepositoryNotifier.new); class RepositoryNotifier extends Notifier { @override Repository build() { final accessToken = ref.watch( persistenceProvider.select((s) => s.accountGroup.account?.accessToken), ); return Repository(accessToken); } Future initAccount(String token, int secondsUntilExpiration) async { try { final data = await Repository( token, ).request('query Viewer {Viewer {id name avatar {large}}}'); final id = data['Viewer']?['id']; final name = data['Viewer']?['name']; final avatarUrl = data['Viewer']?['avatar']?['large']; if (id == null || name == null || avatarUrl == null) { return null; } final expiration = DateTime.now().add(Duration(seconds: secondsUntilExpiration, days: -1)); return Account( id: id, name: name, avatarUrl: avatarUrl, expiration: expiration, accessToken: token, ); } catch (_) { return null; } } } ================================================ FILE: lib/main.dart ================================================ import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/background_handler.dart'; import 'package:otraku/util/theming.dart'; Future main() async { final container = ProviderContainer(retry: (retryCount, error) => null); await container.read(persistenceProvider.notifier).init(); BackgroundHandler.init(_notificationCtrl); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, systemStatusBarContrastEnforced: false, systemNavigationBarColor: Colors.transparent, systemNavigationBarContrastEnforced: false, ), ); runApp(UncontrolledProviderScope(container: container, child: const _App())); } final _notificationCtrl = StreamController.broadcast(); class _App extends ConsumerStatefulWidget { const _App(); @override AppState createState() => AppState(); } class AppState extends ConsumerState<_App> { late final GoRouter _router; late final StreamSubscription _notificationSubscription; Color? _systemLightPrimaryColor; Color? _systemDarkPrimaryColor; @override void initState() { super.initState(); final mustConfirmExit = () => ref.read(persistenceProvider).options.confirmExit; _router = Routes.buildRouter(mustConfirmExit); _notificationSubscription = _notificationCtrl.stream.listen(_router.push); var appMeta = ref.read(persistenceProvider).appMeta; if (appMeta.lastAppVersion != appVersion) { appMeta = AppMeta( lastAppVersion: appVersion, lastNotificationId: appMeta.lastNotificationId, lastBackgroundJob: appMeta.lastBackgroundJob, ); WidgetsBinding.instance.addPostFrameCallback( (_) => ref.read(persistenceProvider.notifier).setAppMeta(appMeta), ); BackgroundHandler.requestPermissionForNotifications(); } } @override void dispose() { _notificationSubscription.cancel(); super.dispose(); } @override Widget build(BuildContext context) { ref.watch(viewerIdProvider); final options = ref.watch(persistenceProvider.select((s) => s.options)); final platformBrightness = MediaQuery.platformBrightnessOf(context); final viewSize = MediaQuery.sizeOf(context); return DynamicColorBuilder( builder: (lightDynamic, darkDynamic) { Color lightSeed = (options.themeBase ?? .navy).seed; Color darkSeed = lightSeed; if (lightDynamic != null && darkDynamic != null) { _systemLightPrimaryColor = lightDynamic.primary; _systemDarkPrimaryColor = darkDynamic.primary; // The system primary colors must be cached, // so they can later be used in the settings. final notifier = ref.watch(persistenceProvider.notifier); // A provider can't be modified during build, // so it's done asynchronously as a workaround. Future( () => notifier.cacheSystemPrimaryColors(( lightPrimaryColor: _systemLightPrimaryColor, darkPrimaryColor: _systemDarkPrimaryColor, )), ); if (options.themeBase == null && _systemLightPrimaryColor != null && _systemDarkPrimaryColor != null) { lightSeed = _systemLightPrimaryColor!; darkSeed = _systemDarkPrimaryColor!; } } Color? lightBackground; Color? darkBackground; if (options.highContrast) { lightBackground = Colors.white; darkBackground = Colors.black; } final lightScheme = ColorScheme.fromSeed( seedColor: lightSeed, brightness: Brightness.light, ).copyWith(surface: lightBackground); final darkScheme = ColorScheme.fromSeed( seedColor: darkSeed, brightness: Brightness.dark, ).copyWith(surface: darkBackground); final isDark = options.themeMode == ThemeMode.system ? platformBrightness == Brightness.dark : options.themeMode == ThemeMode.dark; final ColorScheme scheme; final Brightness overlayBrightness; if (isDark) { scheme = darkScheme; overlayBrightness = Brightness.light; } else { scheme = lightScheme; overlayBrightness = Brightness.dark; } SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle( statusBarBrightness: scheme.brightness, statusBarIconBrightness: overlayBrightness, systemNavigationBarIconBrightness: overlayBrightness, ), ); return MaterialApp.router( debugShowCheckedModeBanner: false, title: 'Otraku', theme: Theming.generateThemeData(lightScheme), darkTheme: Theming.generateThemeData(darkScheme), themeMode: options.themeMode, routerConfig: _router, builder: (context, child) { final directionality = Directionality.of(context); final theming = Theming( formFactor: viewSize.width < Theming.windowWidthMedium ? .phone : .tablet, rightButtonOrientation: options.buttonOrientation == .auto ? directionality == TextDirection.ltr : options.buttonOrientation == .right, ); return Theme( data: Theme.of(context).copyWith(extensions: [theming]), child: child!, ); }, ); }, ); } } ================================================ FILE: lib/util/background_handler.dart ================================================ import 'dart:async'; import 'dart:io'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:otraku/feature/viewer/persistence_model.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/feature/notification/notifications_model.dart'; import 'package:otraku/util/graphql.dart'; import 'package:workmanager/workmanager.dart'; final _notificationPlugin = FlutterLocalNotificationsPlugin(); class BackgroundHandler { BackgroundHandler._(); static Future init(StreamController notificationCtrl) async { _notificationPlugin.initialize( settings: const InitializationSettings( android: AndroidInitializationSettings('notification_icon'), iOS: DarwinInitializationSettings(), ), onDidReceiveNotificationResponse: (response) { if (response.payload == null) return; notificationCtrl.add(response.payload!); }, ); // Check if the app was launched by a notification. _notificationPlugin.getNotificationAppLaunchDetails().then((launchDetails) { if (launchDetails?.notificationResponse?.payload == null) return; notificationCtrl.add(launchDetails!.notificationResponse!.payload!); }); await Workmanager().initialize(_fetch); if (Platform.isAndroid) { Workmanager().registerPeriodicTask( '0', 'notifications', constraints: Constraints(networkType: NetworkType.connected), ); } } /// Requests a notifications permission, if not already granted. static Future requestPermissionForNotifications() async { if (Platform.isAndroid) { final platform = _notificationPlugin .resolvePlatformSpecificImplementation(); if (platform == null) return; if (await platform.areNotificationsEnabled() ?? false) return; await platform.requestNotificationsPermission(); return; } if (Platform.isIOS) { final platform = _notificationPlugin .resolvePlatformSpecificImplementation(); if (platform == null) return; final permissions = await platform.checkPermissions(); if (permissions?.isEnabled ?? false) return; await platform.requestPermissions(sound: true, badge: true); return; } } /// Clears device notifications. static void clearNotifications() => _notificationPlugin.cancelAll(); } @pragma('vm:entry-point') void _fetch() => Workmanager().executeTask((_, _) async { final container = ProviderContainer(retry: (retryCount, error) => null); await container.read(persistenceProvider.notifier).init(); final persistence = container.read(persistenceProvider); // No notifications are fetched in guest mode. if (persistence.accountGroup.accountIndex == null) return true; var appMeta = AppMeta( lastBackgroundJob: DateTime.now(), lastNotificationId: persistence.appMeta.lastNotificationId, lastAppVersion: persistence.appMeta.lastAppVersion, ); container.read(persistenceProvider.notifier).setAppMeta(appMeta); final repository = container.read(repositoryProvider); Map data; try { data = await repository.request(GqlQuery.notifications, const {'withCount': true}); } catch (_) { return true; } int count = data['Viewer']?['unreadNotificationCount'] ?? 0; final List notifications = data['Page']?['notifications'] ?? const []; if (count > notifications.length) count = notifications.length; if (count == 0) return true; final lastNotificationId = persistence.appMeta.lastNotificationId; appMeta = AppMeta( lastNotificationId: notifications[0]['id'] ?? -1, lastBackgroundJob: persistence.appMeta.lastBackgroundJob, lastAppVersion: persistence.appMeta.lastAppVersion, ); container.read(persistenceProvider.notifier).setAppMeta(appMeta); for (int i = 0; i < count && notifications[i]['id'] != lastNotificationId; i++) { final notification = SiteNotification.maybe(notifications[i], persistence.options.imageQuality); if (notification == null) continue; (switch (notification.type) { .following => _show( notification, 'New Follow', Routes.user((notification as FollowNotification).userId), ), .activityMention => _show( notification, 'New Mention', Routes.activity((notification as ActivityNotification).activityId), ), .activityMessage => _show( notification, 'New Message', Routes.activity((notification as ActivityNotification).activityId), ), .activityReply => _show( notification, 'New Reply', Routes.activity((notification as ActivityNotification).activityId), ), .activityReplySubscribed => _show( notification, 'New Reply To Subscribed Activity', Routes.activity((notification as ActivityNotification).activityId), ), .activityLike => _show( notification, 'New Activity Like', Routes.activity((notification as ActivityNotification).activityId), ), .acrivityReplyLike => _show( notification, 'New Reply Like', Routes.activity((notification as ActivityNotification).activityId), ), .threadLike => _show( notification, 'New Forum Like', Routes.thread((notification as ThreadNotification).threadId), ), .threadCommentReply => _show( notification, 'New Forum Reply', Routes.comment((notification as ThreadCommentNotification).commentId), ), .threadCommentMention => _show( notification, 'New Forum Mention', Routes.comment((notification as ThreadCommentNotification).commentId), ), .threadReplySubscribed => _show( notification, 'New Forum Comment', Routes.comment((notification as ThreadCommentNotification).commentId), ), .threadCommentLike => _show( notification, 'New Forum Comment Like', Routes.comment((notification as ThreadCommentNotification).commentId), ), .airing => _show( notification, 'New Episode', Routes.media((notification as MediaReleaseNotification).mediaId), ), .relatedMediaAddition => _show( notification, 'Added Media', Routes.media((notification as MediaReleaseNotification).mediaId), ), .mediaDataChange => _show( notification, 'Modified Media', Routes.media((notification as MediaChangeNotification).mediaId), ), .mediaMerge => _show( notification, 'Merged Media', Routes.media((notification as MediaChangeNotification).mediaId), ), .mediaDeletion => _show(notification, 'Deleted Media', Routes.notifications), .mediaSubmissionUpdate => _show( notification, 'Media Submission Update', Routes.notifications, ), .characterSubmissionUpdate => _show( notification, 'Character Submission Update', Routes.notifications, ), .staffSubmissionUpdate => _show( notification, 'Staff Submission Update', Routes.notifications, ), }); } return true; }); () _show(SiteNotification notification, String title, String payload) { _notificationPlugin.show( id: notification.id, title: title, body: notification.texts.join(), payload: payload, notificationDetails: NotificationDetails( android: AndroidNotificationDetails( notification.type.name, notification.type.label, channelDescription: notification.type.label, ), ), ); return (); } ================================================ FILE: lib/util/debounce.dart ================================================ import 'dart:async'; /// After [_delay] time has passed, since the last [run] call, call [callback]. /// E.g. do a search query after the user stops typing. class Debounce { static const _delay = Duration(milliseconds: 600); Timer? _timer; void cancel() => _timer?.cancel(); void run(void Function() callback) { _timer?.cancel(); _timer = Timer(_delay, callback); } } ================================================ FILE: lib/util/graphql.dart ================================================ abstract class GqlQuery { static const collection = r''' query Collection($userId: Int, $type: MediaType, $status_in: [MediaListStatus]) { MediaListCollection(userId: $userId, type: $type, status_in: $status_in) { lists {name isCustomList isSplitCompletedList status entries {...collectionEntry}} user { mediaListOptions { rowOrder scoreFormat animeList {sectionOrder splitCompletedSectionByFormat} mangaList {sectionOrder splitCompletedSectionByFormat} } } } } ''' '${_GqlFragment.collectionEntry}'; static const listEntry = r''' query CollectionEntry($userId: Int, $mediaId: Int) { MediaList(userId: $userId, mediaId: $mediaId) { ...collectionEntry customLists hiddenFromStatusLists } } ''' '${_GqlFragment.collectionEntry}'; static const media = r''' query Media($id: Int, $withInfo: Boolean = false, $withRecommendations: Boolean = false, $withCharacters: Boolean = false, $withStaff: Boolean = false, $withReviews: Boolean = false, $page: Int = 1) { Media(id: $id) { mediaListEntry @include(if: $withInfo) {...entry} ...info @include(if: $withInfo) ...recommendations @include (if: $withRecommendations) ...characters @include(if: $withCharacters) ...staff @include(if: $withStaff) ...reviews @include(if: $withReviews) } } fragment info on Media { id type title {userPreferred english romaji native} synonyms description coverImage {extraLarge large medium} bannerImage episodes chapters volumes format status(version: 2) startDate {year month day} endDate {year month day} nextAiringEpisode {episode airingAt} countryOfOrigin genres tags {id} isAdult hashtag isFavourite favourites duration season seasonYear averageScore meanScore popularity studios {edges {isMain node {id name}}} tags {name description rank isMediaSpoiler isGeneralSpoiler} source(version: 3) hashtag siteUrl rankings {rank type year season allTime} stats {scoreDistribution {score amount} statusDistribution {status amount}} externalLinks {url site type color language} relations { edges { relationType(version: 2) node { id type format title {userPreferred} status(version: 2) coverImage {extraLarge large medium} mediaListEntry {status} } } } } fragment entry on MediaList { id status progress progressVolumes score repeat notes startedAt {year month day} completedAt {year month day} private hiddenFromStatusLists customLists advancedScores updatedAt createdAt } fragment characters on Media { characters(page: $page, sort: [ROLE, RELEVANCE, ID]) { pageInfo {hasNextPage} edges { role node {id name {userPreferred} image {large}} voiceActors(sort: RELEVANCE) { id name {userPreferred} image {large} languageV2 } } } } fragment staff on Media { staff(page: $page, sort: [RELEVANCE, ID]) { pageInfo {hasNextPage} edges {role node {id name {userPreferred} image {large}}} } } fragment reviews on Media { reviews(sort: RATING_DESC, page: $page) { pageInfo {hasNextPage} nodes { id summary score rating ratingAmount user {id name avatar {large}} } } } fragment recommendations on Media { recommendations(page: $page, sort: [RATING_DESC]) { pageInfo {hasNextPage} nodes { rating userRating mediaRecommendation { id type title {userPreferred} coverImage {extraLarge large medium} format startDate {year} mediaListEntry {status} } } } } '''; static const mediaFollowing = r''' query MediaFollowing($mediaId: Int, $page: Int) { Page(page: $page) { pageInfo {hasNextPage} mediaList(mediaId: $mediaId, isFollowing: true, sort: UPDATED_TIME_DESC) { status score notes user { id name avatar {large} mediaListOptions {scoreFormat} } } } } '''; static const entry = r''' query Entry($mediaId: Int) { Media(id: $mediaId) { id type episodes chapters volumes mediaListEntry { id status progress progressVolumes repeat notes startedAt {year month day} completedAt {year month day} score advancedScores private hiddenFromStatusLists customLists } } } '''; static const mediaPage = r''' query Media($page: Int, $type: MediaType, $search:String, $status_in: [MediaStatus], $format_in: [MediaFormat], $genre_in: [String], $genre_not_in: [String], $tag_in: [String], $tag_not_in: [String], $onList: Boolean, $startFrom: FuzzyDateInt, $startTo: FuzzyDateInt, $countryOfOrigin: CountryCode, $season: MediaSeason, $sources: [MediaSource], $isAdult: Boolean, $isLicensed: Boolean, $sort: [MediaSort]) { Page(page: $page) { pageInfo {hasNextPage} media(type: $type, search: $search, status_in: $status_in, format_in: $format_in, genre_in: $genre_in, genre_not_in: $genre_not_in, tag_in: $tag_in, tag_not_in: $tag_not_in, onList: $onList, startDate_greater: $startFrom, startDate_lesser: $startTo, isAdult: $isAdult, isLicensed: $isLicensed, countryOfOrigin: $countryOfOrigin, season: $season, source_in: $sources, sort: $sort) { id type title {userPreferred} coverImage {extraLarge large medium} format status(version: 2) averageScore popularity startDate {year} isAdult mediaListEntry {status} } } } '''; static const character = r''' query Character($id: Int, $sort: [MediaSort], $page: Int = 1, $onList: Boolean, $withInfo: Boolean = false, $withAnime: Boolean = false, $withManga: Boolean = false) { Character(id: $id) { ...info @include(if: $withInfo) anime: media(page: $page, type: ANIME, onList: $onList, sort: $sort) @include(if: $withAnime) {...media} manga: media(page: $page, type: MANGA, onList: $onList, sort: $sort) @include(if: $withManga) {...media} } } fragment info on Character { id name{first middle last native alternative alternativeSpoiler} image{large} description dateOfBirth{year month day} bloodType gender age favourites isFavourite siteUrl } fragment media on MediaConnection { pageInfo {hasNextPage} edges { characterRole voiceActors(sort: [LANGUAGE]) {id name {userPreferred} image {large} languageV2} node {id type title {userPreferred} coverImage {extraLarge large medium}} } } '''; static const characterPage = r''' query Characters($page: Int, $search: String, $isBirthday: Boolean) { Page(page: $page) { pageInfo {hasNextPage} characters(search: $search, sort: FAVOURITES_DESC, isBirthday: $isBirthday) { id name {userPreferred} image {large} } } } '''; static const staff = r''' query Staff($id: Int, $sort: [MediaSort], $page: Int = 1, $type: MediaType, $onList: Boolean, $withInfo: Boolean = false, $withCharacters: Boolean = false, $withRoles: Boolean = false) { Staff(id: $id) { ...info @include(if: $withInfo) characterMedia(page: $page, sort: $sort, onList: $onList) @include(if: $withCharacters) { pageInfo {hasNextPage} edges { characterRole node { id type title {userPreferred} coverImage {extraLarge large medium} format } characters { id name {userPreferred} image {large} } } } staffMedia(page: $page, sort: $sort, type: $type, onList: $onList) @include(if: $withRoles) { pageInfo {hasNextPage} edges { staffRole node { id type title {userPreferred} coverImage {extraLarge large medium} } } } } } fragment info on Staff { id name{first middle last native alternative} image{large} description dateOfBirth{year month day} dateOfDeath{year month day} gender age yearsActive bloodType homeTown favourites isFavourite siteUrl } '''; static const staffPage = r''' query Staff($page: Int, $search: String, $isBirthday: Boolean) { Page(page: $page) { pageInfo {hasNextPage} staff(search: $search, sort: FAVOURITES_DESC, isBirthday: $isBirthday) { id name {userPreferred} image {large} } } } '''; static const studio = r''' query Studio($id: Int, $page: Int = 1, $sort: [MediaSort], $onList: Boolean, $isMain: Boolean, $withInfo: Boolean = false, $withMedia: Boolean = false) { Studio(id: $id) { ...info @include(if: $withInfo) media(page: $page, sort: $sort, onList: $onList, isMain: $isMain) @include(if: $withMedia) { pageInfo {hasNextPage} nodes { id title {userPreferred} coverImage {extraLarge large medium} format status(version: 2) averageScore mediaListEntry {status} startDate {year month day} } } } } fragment info on Studio {id name favourites isFavourite siteUrl} '''; static const studioPage = r''' query Studios($page: Int, $search: String) { Page(page: $page) { pageInfo {hasNextPage} studios(search: $search, sort: FAVOURITES_DESC) {id name} } } '''; static const review = r''' query Review($id: Int) { Review(id: $id) { id summary body score rating ratingAmount userRating createdAt siteUrl media {id type title {userPreferred} coverImage {extraLarge large medium} bannerImage} user {id name avatar {large}} } } '''; static const reviewPage = r''' query Reviews($userId: Int, $page: Int = 1, $mediaType: MediaType, $sort: [ReviewSort]) { Page(page: $page) { pageInfo {hasNextPage total} reviews(userId: $userId, mediaType: $mediaType, sort: $sort) { id summary rating ratingAmount media {id type title {userPreferred} bannerImage} user {id name} } } } '''; static const user = r''' query User($id: Int, $name: String) { User(id: $id, name: $name) { id name createdAt about avatar {large} bannerImage isFollowing isFollower isBlocked siteUrl donatorTier donatorBadge moderatorRoles statistics {anime {...stats} manga {...stats}} } } fragment stats on UserStatistics { count meanScore standardDeviation minutesWatched episodesWatched chaptersRead volumesRead scores(sort: MEAN_SCORE) {count meanScore minutesWatched chaptersRead score} lengths {count meanScore minutesWatched chaptersRead length} formats {count meanScore minutesWatched chaptersRead format} statuses {count meanScore minutesWatched chaptersRead status} countries {count meanScore minutesWatched chaptersRead country} } '''; static const userPage = r''' query Users($page: Int, $search: String) { Page(page: $page) { pageInfo {hasNextPage} users(search: $search) {id name avatar {large}} } } '''; static const recommendationsPage = r''' query Recommendations($page: Int, $sort: [RecommendationSort], $onList: Boolean) { Page(page: $page, perPage: 30) { pageInfo {hasNextPage} recommendations(sort: $sort, onList: $onList) { rating userRating media { id type title {userPreferred} coverImage {extraLarge large medium} mediaListEntry {status} isAdult } mediaRecommendation { id type title {userPreferred} coverImage {extraLarge large medium} mediaListEntry {status} isAdult } } } } '''; static const calendar = r''' query Calendar($page: Int, $airingFrom: Int, $airingTo: Int) { Page(page: $page) { pageInfo {hasNextPage} airingSchedules(airingAt_greater: $airingFrom, airingAt_lesser: $airingTo) { airingAt episode mediaId media { title {userPreferred} coverImage {extraLarge large medium} season seasonYear mediaListEntry {status} externalLinks {url site type color language} } } } } '''; static const favorites = r''' query Favorites($userId: Int, $page: Int = 1, $withAnime: Boolean = false, $withManga: Boolean = false, $withCharacters: Boolean = false, $withStaff: Boolean = false, $withStudios: Boolean = false) { User(id: $userId) { favourites { anime(page: $page) @include(if: $withAnime) {...media} manga(page: $page) @include(if: $withManga) {...media} characters(page: $page) @include(if: $withCharacters) {...character} staff(page: $page) @include(if: $withStaff) {...staff} studios(page: $page) @include(if: $withStudios) {...studio} } } } fragment media on MediaConnection {pageInfo {hasNextPage total} nodes {id title {userPreferred} coverImage {extraLarge large medium}}} fragment character on CharacterConnection {pageInfo {hasNextPage total} nodes {id name {userPreferred} image {large}}} fragment staff on StaffConnection {pageInfo {hasNextPage total} nodes {id name {userPreferred} image {large}}} fragment studio on StudioConnection {pageInfo {hasNextPage total} nodes {id name}} '''; static const social = r''' query Friends($userId: Int!, $page: Int = 1, $withFollowing: Boolean = false, $withFollowers: Boolean = false, $withThreads: Boolean = false, $withComments: Boolean = false) { following: Page(page: $page) @include(if: $withFollowing) { pageInfo {hasNextPage total} following(userId: $userId, sort: USERNAME) {id name avatar {large}} } followers: Page(page: $page) @include(if: $withFollowers) { pageInfo {hasNextPage total} followers(userId: $userId, sort: USERNAME) {id name avatar {large}} } threads: Page(page: $page) @include(if: $withThreads) { pageInfo {hasNextPage total} threads(userId: $userId, sort: ID_DESC) {...thread} } comments: Page(page: $page) @include(if: $withComments) { pageInfo {hasNextPage total} threadComments(userId: $userId, sort: ID_DESC) { id comment likeCount isLiked isLocked createdAt siteUrl user {id name avatar {large}} thread {id title} } } } ''' '${_GqlFragment.thread}'; static const activity = r''' query Activity($id: Int, $withActivity: Boolean = false, $page: Int = 1) { Activity(id: $id) @include(if: $withActivity) { ... on TextActivity {...textActivity} ... on ListActivity {...listActivity} ... on MessageActivity {...messageActivity} } Page(page: $page) { pageInfo {hasNextPage} activityReplies(activityId: $id) {...activityReply} } } ''' '${_GqlFragment.textActivity}${_GqlFragment.listActivity}${_GqlFragment.messageActivity}${_GqlFragment.activityReply}'; static const activityPage = r''' query Activities($userId: Int, $userIdNot: Int, $mediaId: Int, $page: Int = 1, $isFollowing: Boolean, $hasRepliesOrText: Boolean, $typeIn: [ActivityType], $createdBefore: Int) { Page(page: $page) { pageInfo {hasNextPage} activities(userId: $userId, userId_not: $userIdNot, mediaId: $mediaId, isFollowing: $isFollowing, hasRepliesOrTypeText: $hasRepliesOrText, type_in: $typeIn, createdAt_lesser: $createdBefore, sort: [PINNED, ID_DESC]) { ... on TextActivity {...textActivity} ... on ListActivity {...listActivity} ... on MessageActivity {...messageActivity} } } } ''' '${_GqlFragment.textActivity}${_GqlFragment.listActivity}${_GqlFragment.messageActivity}'; static const activityComposition = r''' query ActivityComposition($id: Int) { Activity(id: $id) { ... on TextActivity {text} ... on ListActivity {id} ... on MessageActivity {message} } } '''; static const activityReplyComposition = r''' query ActivityReplyComposition($id: Int) { ActivityReply(id: $id) {text} } '''; static const commentComposition = r''' query CommentComposition($id: Int) { ThreadComment(id: $id) {id comment childComments} } '''; static const settings = r''' query Settings($withData: Boolean = true) { Viewer { unreadNotificationCount ...userSettings @include(if: $withData) } } ''' '${_GqlFragment.userSettings}'; static const threadPage = r''' query Forum($page: Int = 1, $search: String, $categoryId: Int, $mediaId: Int, $subscribed: Boolean, $userId: Int, $replyUserId: Int, $sort: [ThreadSort]) { Page(page: $page) { pageInfo {hasNextPage} threads(search: $search, categoryId: $categoryId, mediaCategoryId: $mediaId, subscribed: $subscribed, userId: $userId, replyUserId: $replyUserId, sort: $sort) { ...thread } } } ''' '${_GqlFragment.thread}'; static const thread = r''' query Thread($id: Int, $withInfo: Boolean = false, $page: Int = 1) { Thread(id: $id) @include(if: $withInfo) { id title body viewCount likeCount replyCount isLiked isSubscribed isSticky isLocked createdAt siteUrl categories {name} mediaCategories {id title {userPreferred} coverImage {extraLarge large medium}} user {id name avatar {large}} } Page(page: $page, perPage: 15) { pageInfo {currentPage lastPage} threadComments(threadId: $id) { id comment likeCount isLiked isLocked createdAt siteUrl user {id name avatar {large}} thread {id title} childComments } } } '''; static const comment = r''' query Comment($id: Int) { ThreadComment(id: $id) { id comment likeCount isLiked isLocked createdAt siteUrl user {id name avatar {large}} thread {id title} childComments } } '''; static const notifications = r''' query Notifications($page: Int = 1, $filter: [NotificationType], $withCount: Boolean = false, $resetCount: Boolean = false) { Viewer @include(if: $withCount) {unreadNotificationCount} Page(page: $page) { pageInfo {hasNextPage} notifications(type_in: $filter, resetNotificationCount: $resetCount) { ... on FollowingNotification { id type user {id name avatar {large}} createdAt } ... on ActivityMentionNotification { id type activityId user {id name avatar {large}} createdAt } ... on ActivityMessageNotification { id type activityId user {id name avatar {large}} createdAt } ... on ActivityLikeNotification { id type activityId user {id name avatar {large}} createdAt } ... on ActivityReplyNotification { id type activityId user {id name avatar {large}} createdAt } ... on ActivityReplyLikeNotification { id type activityId user {id name avatar {large}} createdAt } ... on ActivityReplySubscribedNotification { id type activityId user {id name avatar {large}} createdAt } ... on ThreadLikeNotification { id type thread {id title siteUrl} user {id name avatar {large}} createdAt } ... on ThreadCommentLikeNotification { id type thread {title} comment {id siteUrl} user {id name avatar {large}} createdAt } ... on ThreadCommentReplyNotification { id type context thread {title} comment {id siteUrl} user {id name avatar {large}} createdAt } ... on ThreadCommentMentionNotification { id type thread {title} comment {id siteUrl} user {id name avatar {large}} createdAt } ... on ThreadCommentSubscribedNotification { id type thread {title} comment {id siteUrl} user {id name avatar {large}} createdAt } ... on RelatedMediaAdditionNotification { id type media {id title {userPreferred} coverImage {extraLarge large medium}} createdAt } ... on MediaDataChangeNotification { id type reason media {id title {userPreferred} coverImage {extraLarge large medium}} createdAt } ... on MediaMergeNotification { id type reason deletedMediaTitles media {id title {userPreferred} coverImage {extraLarge large medium}} createdAt } ... on MediaDeletionNotification { id type reason deletedMediaTitle createdAt } ... on AiringNotification { id type episode media {id title {userPreferred} coverImage {extraLarge large medium}} createdAt } ... on MediaSubmissionUpdateNotification { id type status notes media {id title {userPreferred} coverImage {extraLarge large medium}} submittedTitle createdAt } ... on CharacterSubmissionUpdateNotification { id type status notes character {id name {userPreferred} image {large medium}} createdAt } ... on StaffSubmissionUpdateNotification { id type status notes staff {id name {userPreferred} image {large medium}} createdAt } } } } '''; static const genresAndTags = ''' query Filters { GenreCollection MediaTagCollection {id name description category} } '''; } abstract class GqlMutation { static const updateEntry = r''' mutation UpdateEntry($mediaId: Int, $status: MediaListStatus, $score: Float, $progress: Int, $progressVolumes: Int, $repeat: Int, $private: Boolean, $notes: String, $hiddenFromStatusLists: Boolean, $customLists: [String], $startedAt: FuzzyDateInput, $completedAt: FuzzyDateInput, $advancedScores: [Float]) { SaveMediaListEntry(mediaId: $mediaId, status: $status, score: $score, progress: $progress, progressVolumes: $progressVolumes, repeat: $repeat, private: $private, notes: $notes, hiddenFromStatusLists: $hiddenFromStatusLists, customLists: $customLists, startedAt: $startedAt, completedAt: $completedAt, advancedScores: $advancedScores) {id} } '''; static const updateProgress = r''' mutation UpdateProgress($mediaId: Int, $progress: Int, $status: MediaListStatus, $startedAt: FuzzyDateInput) { SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status, startedAt: $startedAt) {id} } '''; static const removeEntry = r''' mutation RemoveEntry($entryId: Int) {DeleteMediaListEntry(id: $entryId) {deleted}} '''; static const updateSettings = r''' mutation UpdateSettings($titleLanguage: UserTitleLanguage, $staffNameLanguage: UserStaffNameLanguage, $activityMergeTime: Int, $displayAdultContent: Boolean, $airingNotifications: Boolean, $scoreFormat: ScoreFormat, $rowOrder: String, $notificationOptions: [NotificationOptionInput], $splitCompletedAnime: Boolean, $splitCompletedManga: Boolean, $restrictMessagesToFollowing: Boolean, $advancedScoringEnabled: Boolean, $advancedScoring: [String], $disabledListActivity: [ListActivityOptionInput], $animeCustomLists: [String], $mangaCustomLists: [String]) { UpdateUser(titleLanguage: $titleLanguage, staffNameLanguage: $staffNameLanguage, activityMergeTime: $activityMergeTime, displayAdultContent: $displayAdultContent, airingNotifications: $airingNotifications, restrictMessagesToFollowing: $restrictMessagesToFollowing, scoreFormat: $scoreFormat, rowOrder: $rowOrder, notificationOptions: $notificationOptions, disabledListActivity: $disabledListActivity, animeListOptions: {splitCompletedSectionByFormat: $splitCompletedAnime, customLists: $animeCustomLists, advancedScoringEnabled: $advancedScoringEnabled, advancedScoring: $advancedScoring}, mangaListOptions: {splitCompletedSectionByFormat: $splitCompletedManga, customLists: $mangaCustomLists}) { ...userSettings } } ''' '${_GqlFragment.userSettings}'; static const reorderFavorites = r''' mutation ReorderFavorites($animeIds: [Int], $animeOrder: [Int], $mangaIds: [Int], $mangaOrder: [Int], $characterIds: [Int], $characterOrder: [Int], $staffIds: [Int], $staffOrder: [Int], $studioIds: [Int], $studioOrder: [Int]) { UpdateFavouriteOrder(animeIds: $animeIds, animeOrder: $animeOrder, mangaIds: $mangaIds, mangaOrder: $mangaOrder, characterIds: $characterIds, characterOrder: $characterOrder, staffIds: $staffIds, staffOrder: $staffOrder, studioIds: $studioIds, studioOrder: $studioOrder) { anime {pageInfo {total}} } } '''; static const toggleFavorite = r''' mutation ToggleFavorite($anime: Int, $manga: Int, $character: Int, $staff: Int, $studio: Int) { ToggleFavourite(animeId: $anime, mangaId: $manga, characterId: $character, staffId: $staff, studioId: $studio) { anime(page: 1, perPage: 1) {nodes{isFavourite}} manga(page: 1, perPage: 1) {nodes{isFavourite}} characters(page: 1, perPage: 1) {nodes{isFavourite}} staff(page: 1, perPage: 1) {nodes{isFavourite}} studios(page: 1, perPage: 1) {nodes{isFavourite}} } } '''; static const toggleFollow = r'''mutation ToggleFollow($userId: Int) {ToggleFollow(userId: $userId) {isFollowing}}'''; static const rateReview = r''' mutation RateReview($id: Int, $rating: ReviewRating) { RateReview(reviewId: $id, rating: $rating) { rating ratingAmount userRating } } '''; static const rateRecommendation = r''' mutation RateRecommendation($id: Int, $recommendedId: Int, $rating: RecommendationRating) { SaveRecommendation(mediaId: $id, mediaRecommendationId: $recommendedId, rating: $rating) {id} } '''; static const saveStatusActivity = r''' mutation SaveStatusActivity($id: Int, $text: String) { SaveTextActivity(id: $id, text: $text) {...textActivity} } ''' '${_GqlFragment.textActivity}'; static const saveMessageActivity = r''' mutation SaveMessageActivity($id: Int, $recipientId: Int, $text: String, $isPrivate: Boolean) { SaveMessageActivity(id: $id, recipientId: $recipientId, message: $text, private: $isPrivate) {...messageActivity} } ''' '${_GqlFragment.messageActivity}'; static const saveActivityReply = r''' mutation SaveActivityReply($id: Int, $activityId: Int, $text: String) { SaveActivityReply(id: $id, activityId: $activityId, text: $text) {...activityReply} } ''' '${_GqlFragment.activityReply}'; static const saveComment = r''' mutation SaveComment($id: Int, $threadId: Int, $parentCommentId: Int, $text: String) { SaveThreadComment(id: $id, threadId: $threadId, parentCommentId: $parentCommentId, comment: $text) { id comment likeCount isLiked isLocked createdAt siteUrl user {id name avatar {large}} thread {id title} childComments } } '''; static const toggleLike = r''' mutation ToggleLike($id: Int, $type: LikeableType) { ToggleLikeV2(id: $id, type: $type) { ... on ListActivity {likeCount isLiked} ... on TextActivity {likeCount isLiked} ... on MessageActivity {likeCount isLiked} ... on ActivityReply {likeCount isLiked} ... on Thread {likeCount isLiked} ... on ThreadComment {likeCount isLiked} } } '''; static const toggleActivitySubscription = r''' mutation ToggleActivitySubscription($id: Int, $subscribe: Boolean) { ToggleActivitySubscription(activityId: $id, subscribe: $subscribe) { ... on ListActivity {isSubscribed} ... on TextActivity {isSubscribed} ... on MessageActivity {isSubscribed} } } '''; static const toggleActivityPin = r''' mutation ToggleActivityPin($id: Int, $pinned: Boolean) { ToggleActivityPin(id: $id, pinned: $pinned) { ... on ListActivity {isPinned} ... on TextActivity {isPinned} } } '''; static const deleteActivity = r''' mutation DeleteActivity($id: Int) {DeleteActivity(id: $id) {deleted}} '''; static const deleteActivityReply = r''' mutation DeleteActivityReply($id: Int) {DeleteActivityReply(id: $id) {deleted}} '''; static const toggleThreadSubscription = r''' mutation ToggleThreadSubscription($id: Int, $subscribe: Boolean) { ToggleThreadSubscription(threadId: $id, subscribe: $subscribe) { isSubscribed } } '''; static const deleteThread = r''' mutation DeleteThread($id: Int) {DeleteThread(id: $id) {deleted}} '''; static const deleteComment = r''' mutation DeleteThreadComment($id: Int) {DeleteThreadComment(id: $id) {deleted}} '''; } abstract class _GqlFragment { static const collectionEntry = r''' fragment collectionEntry on MediaList { status progress score notes private repeat startedAt {year month day} completedAt {year month day} createdAt updatedAt media { id title {userPreferred romaji english native} coverImage {extraLarge large medium} format status episodes chapters averageScore genres tags {id} nextAiringEpisode {episode airingAt} startDate {year month day} countryOfOrigin } } '''; static const userSettings = r''' fragment userSettings on User { options { titleLanguage staffNameLanguage activityMergeTime displayAdultContent airingNotifications notificationOptions {type enabled} restrictMessagesToFollowing disabledListActivity {type disabled} } mediaListOptions { scoreFormat rowOrder animeList {splitCompletedSectionByFormat customLists advancedScoring advancedScoringEnabled} mangaList {splitCompletedSectionByFormat customLists} } } '''; static const textActivity = r''' fragment textActivity on TextActivity { id type replyCount likeCount isLiked isSubscribed isPinned createdAt siteUrl text user {id name avatar {large}} } '''; static const messageActivity = r''' fragment messageActivity on MessageActivity { id type replyCount likeCount isLiked isSubscribed isPrivate createdAt siteUrl message messenger {id name avatar {large}} recipient {id name avatar {large}} } '''; static const activityReply = r''' fragment activityReply on ActivityReply { id likeCount isLiked createdAt text user {id name avatar {large}} } '''; static const listActivity = r''' fragment listActivity on ListActivity { id type replyCount likeCount isLiked isSubscribed isPinned createdAt siteUrl user {id name avatar {large}} media {id type title {userPreferred} coverImage {extraLarge large medium} format} progress status } '''; static const thread = r''' fragment thread on Thread { id title viewCount likeCount replyCount isSubscribed isSticky isLocked siteUrl createdAt repliedAt categories {name} mediaCategories {title {userPreferred}} user {id name avatar {large}} replyUser {id name avatar {large}} } '''; } ================================================ FILE: lib/util/markdown.dart ================================================ import 'package:markdown/markdown.dart'; String parseMarkdown(String markdown) { // In case there's raw text, everything is wrapped in a paragraph tag. final nodes = [Element('p', document.parse(markdown))]; return renderToHtml(nodes); } final document = Document( blockSyntaxes: const [ _HeaderSyntax(), _SpoilerBlockSyntax(), _CenterBlockSyntax(), _FencedCodeBlockSyntax(), HorizontalRuleSyntax(), BlockquoteSyntax(), UnorderedListSyntax(), OrderedListSyntax(), ], inlineSyntaxes: [ EmphasisSyntax.asterisk(), EmphasisSyntax.underscore(), StrikethroughSyntax(), CodeSyntax(), LinkSyntax(), AutolinkExtensionSyntax(), ImageSyntax(), _ImageSyntax(), _YouTubeSyntax(), _VideoSyntax(), _MentionSyntax(), _LineBreakSyntax(), ], encodeHtml: false, withDefaultBlockSyntaxes: false, withDefaultInlineSyntaxes: false, extensionSet: null, linkResolver: null, imageLinkResolver: null, ); /// AniList allows empty spaces to be skipped after the sequence of "#". class _HeaderSyntax extends HeaderSyntax { const _HeaderSyntax(); static final _pattern = RegExp(r'^ {0,3}(#{1,6})(?:.*?)?(?:(#*)\s*)?$'); @override RegExp get pattern => _pattern; @override Node parse(BlockParser parser) { final node = super.parse(parser) as Element; // Directly parse inner content. final children = node.children; if (children != null && children.isNotEmpty) { final parsedContent = BlockParser([ Line(children[0].textContent), ], parser.document).parseLines(); children.clear(); children.addAll(parsedContent); } return node; } } abstract class _DelimitedBlockSyntax extends BlockSyntax { const _DelimitedBlockSyntax({ required this.tag, required this.startDelimiter, required this.endDelimiter, }); final String tag; final String startDelimiter; final String endDelimiter; void finalizeElement(Element element); @override Node parse(BlockParser parser) { final lines = parseChildLines(parser); if (lines.length < 3) return Element.withTag(tag); final prefix = lines.first.content.isNotEmpty ? BlockParser([lines.first], parser.document).parseLines() : const []; final postfix = lines.last.content.isNotEmpty ? BlockParser([lines.last], parser.document).parseLines() : const []; final children = BlockParser(lines.sublist(1, lines.length - 1), parser.document).parseLines(); final element = Element(tag, children); finalizeElement(element); return prefix.isEmpty && postfix.isEmpty ? element : Element('p', [...prefix, element, ...postfix]); } @override List parseChildLines(BlockParser parser) { final childLines = []; final text = parser.current.content; int startIndex = text.indexOf(startDelimiter); childLines.add(Line(text.substring(0, startIndex))); startIndex += startDelimiter.length; if (startIndex < text.length) { final lineEnd = Line(text.substring(startIndex)); if (_close(parser, childLines, lineEnd)) return childLines; } else { parser.advance(); } while (!parser.isDone) { if (_close(parser, childLines, parser.current)) return childLines; } return childLines; } bool _close(BlockParser parser, List childLines, Line line) { final text = line.content; int endIndex = text.indexOf(endDelimiter); if (endIndex < 0) { childLines.add(line); parser.advance(); return false; } childLines.add(Line(text.substring(0, endIndex))); childLines.add(Line(text.substring(endIndex + endDelimiter.length))); parser.advance(); return true; } } class _SpoilerBlockSyntax extends _DelimitedBlockSyntax { const _SpoilerBlockSyntax() : super(tag: 'details', startDelimiter: _startDelimiter, endDelimiter: '!~'); static const _startDelimiter = '~!'; static final _pattern = RegExp(_startDelimiter); @override RegExp get pattern => _pattern; @override void finalizeElement(Element element) { element.children?.insert(0, Element.text('summary', 'Spoiler')); } } class _CenterBlockSyntax extends _DelimitedBlockSyntax { const _CenterBlockSyntax() : super(tag: 'center', startDelimiter: _delimiter, endDelimiter: _delimiter); static const _delimiter = '~~~'; static final _pattern = RegExp(_delimiter); @override RegExp get pattern => _pattern; @override void finalizeElement(Element element) {} } /// AniList markdown treats content surrounded with "~~~" as centered, /// instead of code, so it should be excluded from this pattern. class _FencedCodeBlockSyntax extends FencedCodeBlockSyntax { const _FencedCodeBlockSyntax(); static final _pattern = RegExp(r'^([ ]{0,3})(?`{3,})(?[^`]*)$'); @override RegExp get pattern => _pattern; } /// AniList always accepts a line break, unlike standard markdown. class _LineBreakSyntax extends InlineSyntax { _LineBreakSyntax() : super(r'\n', startCharacter: 10); @override bool onMatch(InlineParser parser, Match match) { parser.addNode(Element.empty('br')); return true; } } /// Besides the standard markdown image syntax, /// AniList allows for an additional way to embed images. class _ImageSyntax extends InlineSyntax { _ImageSyntax() : super(r'img((?:\d+%?)?)\(((?:https:\/\/)[^)]+)\)', caseSensitive: false); @override bool onMatch(InlineParser parser, Match match) { parser.addNode( Element.empty('img') ..attributes['width'] = match.group(1)! ..attributes['src'] = match.group(2)!, ); return true; } } /// YouTube videos are embedded with syntax different from other web videos. class _YouTubeSyntax extends InlineSyntax { _YouTubeSyntax() : super( r'youtube\s?\(\s*(?:(?:https:\/\/)?(?:www\.)?(?:(?:(?:music\.)?youtube\.com\/watch\?v=)|(?:youtu\.be\/)))?([^?&#)]+)(?:[^)]*)\)', caseSensitive: false, ); @override bool onMatch(InlineParser parser, Match match) { parser.addNode(Element.text('youtube', match.group(1)!)); return true; } } class _VideoSyntax extends InlineSyntax { _VideoSyntax() : super(r'webm\(([^)]+)\)', caseSensitive: false); @override bool onMatch(InlineParser parser, Match match) { parser.addNode( Element('video', [Element.empty('source')..attributes['src'] = match.group(1)!]), ); return true; } } class _MentionSyntax extends InlineSyntax { _MentionSyntax() : super(r'\B@([A-Za-z0-9]+)', startCharacter: 64); @override bool onMatch(InlineParser parser, Match match) { final name = match.group(1)!; parser.addNode( Element.text('a', '@$name')..attributes['href'] = 'https://anilist.co/user/$name', ); return true; } } ================================================ FILE: lib/util/paged.dart ================================================ /// Collection for pagination. class Paged { const Paged({this.items = const [], this.hasNext = true, this.next = 1}); final List items; /// If there's another page to load. final bool hasNext; /// The index of the next page to be loaded. final int next; /// Recreate with another page loaded. Paged withNext(List items, bool hasNext) => Paged(items: [...this.items, ...items], hasNext: hasNext, next: next + 1); } class PagedWithTotal extends Paged { const PagedWithTotal({super.items, super.hasNext, super.next, this.total = 0}); /// Count of all items, even the ones that aren't yet loaded. final int total; @override PagedWithTotal withNext(List items, bool hasNext, [int? total]) => PagedWithTotal( items: [...this.items, ...items], hasNext: hasNext, next: next + 1, total: total ?? this.total, ); } ================================================ FILE: lib/util/paged_controller.dart ================================================ import 'package:flutter/widgets.dart'; /// A [ScrollController] that can perform and action when /// the bottom of the page is reached. Used for pagination. class PagedController extends ScrollController { PagedController({required this.loadMore}) { addListener(_listener); } /// The callback to call, when the end of the page is reached. /// While it can be replaced, do so only if absolutely needed. void Function() loadMore; /// Keeps track of the last [position.maxScrollExtent]. /// Used to ensure that when the end of the page is reached, /// only one call to [loadMore] is performed, at least until /// the bottom of the newly expanded page is reached. double _lastMaxExtent = 0; /// When the user reaches the bottom, try loading more data. void _listener() { if (!hasClients) return; if (positions.last.pixels < positions.last.maxScrollExtent - 100) return; if (_lastMaxExtent == positions.last.maxScrollExtent) return; _lastMaxExtent = positions.last.maxScrollExtent; loadMore(); } /// When a scrollable is detached, [_lastMaxExtent] needs to be reset, so /// that it would work properly, if the scrollable gets attached again. @override void detach(ScrollPosition position) { _lastMaxExtent = 0; super.detach(position); } } ================================================ FILE: lib/util/routes.dart ================================================ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/iterable_extension.dart'; import 'package:otraku/feature/activity/activities_model.dart'; import 'package:otraku/feature/comment/comment_view.dart'; import 'package:otraku/feature/forum/forum_view.dart'; import 'package:otraku/feature/thread/thread_view.dart'; import 'package:otraku/feature/viewer/persistence_provider.dart'; import 'package:otraku/feature/viewer/repository_provider.dart'; import 'package:otraku/widget/layout/top_bar.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/feature/activity/activities_view.dart'; import 'package:otraku/feature/activity/activity_view.dart'; import 'package:otraku/feature/calendar/calendar_view.dart'; import 'package:otraku/feature/character/character_view.dart'; import 'package:otraku/feature/collection/collection_view.dart'; import 'package:otraku/feature/favorites/favorites_view.dart'; import 'package:otraku/feature/home/home_model.dart'; import 'package:otraku/feature/home/home_view.dart'; import 'package:otraku/feature/media/media_view.dart'; import 'package:otraku/feature/notification/notifications_view.dart'; import 'package:otraku/feature/review/review_view.dart'; import 'package:otraku/feature/review/reviews_view.dart'; import 'package:otraku/feature/settings/settings_view.dart'; import 'package:otraku/feature/social/social_view.dart'; import 'package:otraku/feature/staff/staff_view.dart'; import 'package:otraku/feature/statistics/statistics_view.dart'; import 'package:otraku/feature/studio/studio_view.dart'; import 'package:otraku/feature/user/user_providers.dart'; import 'package:otraku/feature/user/user_view.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:url_launcher/url_launcher.dart'; class Routes { const Routes._(); static const notFound = '/404'; static const settings = '/settings'; static const notifications = '/notifications'; static const calendar = '/calendar'; static String home([HomeTab? tab]) => '/home${tab != null ? "?tab=${tab.name}" : ""}'; static String media(int id, [String? imageUrl]) => '/media/$id${imageUrl != null ? "?image=$imageUrl" : ""}'; static String character(int id, [String? imageUrl]) => '/character/$id${imageUrl != null ? "?image=$imageUrl" : ""}'; static String staff(int id, [String? imageUrl]) => '/staff/$id${imageUrl != null ? "?image=$imageUrl" : ""}'; static String user(int id, [String? imageUrl]) => '/user/$id${imageUrl != null ? "?image=$imageUrl" : ""}'; static String userByName(String name, [String? imageUrl]) => '/user/$name${imageUrl != null ? "?image=$imageUrl" : ""}'; static String studio(int id, [String? name]) => '/studio/$id${name != null ? "?name=$name" : ""}'; static String review(int id, [String? imageUrl]) => '/review/$id${imageUrl != null ? "?image=$imageUrl" : ""}'; static String activity(int id, [ActivitiesTag? tag]) => '/activity/$id${tag != null ? "?feed=${tag.toQueryParam()}" : ""}'; static const forum = '/forum'; static String thread(int id) => '/thread/$id'; static String comment(int id) => '/comment/$id'; static String animeCollection(int id) => '/collection/anime/$id'; static String mangaCollection(int id) => '/collection/manga/$id'; static String activities(int id) => '/activities/$id'; static String favorites(int id) => '/favorites/$id'; static String social(int id) => '/social/$id'; static String reviews(int id) => '/reviews/$id'; static String statistics(int id) => '/statistics/$id'; static GoRouter buildRouter(bool Function() mustConfirmExit) { final onExit = (BuildContext context, GoRouterState _) async { if (!mustConfirmExit()) return Future.value(true); var exit = false; await ConfirmationDialog.show( context, title: 'Exit?', primaryAction: 'Yes', secondaryAction: 'No', onConfirm: () => exit = true, ); return exit; }; final routes = [ GoRoute(path: '/', redirect: (context, state) => '/home'), GoRoute( path: '/auth', builder: (context, state) { final fragment = state.uri.fragment; if (fragment.isEmpty) return const _AuthView(null); final start = fragment.indexOf('=') + 1; final middle = fragment.indexOf('&'); final end = fragment.lastIndexOf('=') + 1; final token = fragment.substring(start, middle); final expiration = int.tryParse(fragment.substring(end)) ?? -1; if (token.isEmpty || expiration <= 0) return const _AuthView(null); return _AuthView((token, expiration)); }, ), GoRoute(path: '/404', builder: (context, state) => const NotFoundView()), GoRoute( path: '/home', onExit: onExit, redirect: (context, state) { final tabName = state.uri.queryParameters['tab']; if (tabName == null) return null; final tab = HomeTab.values.firstWhereOrNull((e) => e.name == tabName); return tab != null ? null : notFound; }, builder: (context, state) { final tabName = state.uri.queryParameters['tab']; return HomeView( key: state.pageKey, tab: tabName != null ? HomeTab.values.byName(tabName) : null, ); }, ), GoRoute(path: '/settings', builder: (context, state) => const SettingsView()), GoRoute(path: '/notifications', builder: (context, state) => const NotificationsView()), GoRoute(path: '/calendar', builder: (context, state) => const CalendarView()), GoRoute( path: '/media/:id', redirect: _parseIdOr404, builder: (context, state) => MediaView(int.parse(state.pathParameters['id']!), state.uri.queryParameters['image']), ), GoRoute( path: '/character/:id', redirect: _parseIdOr404, builder: (context, state) => CharacterView( int.parse(state.pathParameters['id']!), state.uri.queryParameters['image'], ), ), GoRoute( path: '/staff/:id', redirect: _parseIdOr404, builder: (context, state) => StaffView(int.parse(state.pathParameters['id']!), state.uri.queryParameters['image']), ), GoRoute( path: '/user/:idOrName', builder: (context, state) { final param = state.pathParameters['idOrName']!; final id = int.tryParse(param); final tag = id != null ? idUserTag(id) : nameUserTag(param); return UserView(tag, state.uri.queryParameters['image']); }, ), GoRoute( path: '/studio/:id', redirect: _parseIdOr404, builder: (context, state) => StudioView(int.parse(state.pathParameters['id']!), state.uri.queryParameters['name']), ), GoRoute( path: '/review/:id', redirect: _parseIdOr404, builder: (context, state) => ReviewView(int.parse(state.pathParameters['id']!), state.uri.queryParameters['image']), ), GoRoute( path: '/activity/:id', redirect: _parseIdOr404, builder: (context, state) => ActivityView( int.parse(state.pathParameters['id']!), ActivitiesTag.fromQueryParam(state.uri.queryParameters['feed'] ?? ''), ), ), GoRoute(path: '/forum', builder: (context, state) => const ForumView()), GoRoute( path: '/thread/:id', redirect: _parseIdOr404, builder: (context, state) => ThreadView(int.parse(state.pathParameters['id']!)), ), GoRoute( path: '/comment/:id', redirect: _parseIdOr404, builder: (context, state) => CommentView(int.parse(state.pathParameters['id']!)), ), GoRoute( path: '/collection/anime/:id', redirect: _parseIdOr404, builder: (context, state) => CollectionView(int.parse(state.pathParameters['id']!), true), ), GoRoute( path: '/collection/manga/:id', redirect: _parseIdOr404, builder: (context, state) => CollectionView(int.parse(state.pathParameters['id']!), false), ), GoRoute( path: '/activities/:id', redirect: _parseIdOr404, builder: (context, state) { final userId = int.parse(state.pathParameters['id']!); return ActivitiesView(UserActivitiesTag(userId)); }, ), GoRoute( path: '/favorites/:id', redirect: _parseIdOr404, builder: (context, state) => FavoritesView(int.parse(state.pathParameters['id']!)), ), GoRoute( path: '/social/:id', redirect: _parseIdOr404, builder: (context, state) => SocialView(int.parse(state.pathParameters['id']!)), ), GoRoute( path: '/reviews/:id', redirect: _parseIdOr404, builder: (context, state) => ReviewsView(int.parse(state.pathParameters['id']!)), ), GoRoute( path: '/statistics/:id', redirect: _parseIdOr404, builder: (context, state) => StatisticsView(int.parse(state.pathParameters['id']!)), ), // Extra routes for AniList deep links: // - Media endpoints are split between anime/manga. // - Comments are thread sub-routes and threads are forum sub-routes. // - Paths can contain superfluous information after the path parameter. GoRoute( path: '/anime/:id', redirect: (context, state) => '/media/${state.pathParameters['id']}', ), GoRoute( path: '/manga/:id', redirect: (context, state) => '/media/${state.pathParameters['id']}', ), GoRoute( path: '/anime/:id/:_(.*)', redirect: (context, state) => '/media/${state.pathParameters['id']}', ), GoRoute( path: '/manga/:id/:_(.*)', redirect: (context, state) => '/media/${state.pathParameters['id']}', ), GoRoute( path: '/character/:id/:_(.*)', redirect: (context, state) => '/character/${state.pathParameters['id']}', ), GoRoute( path: '/staff/:id/:_(.*)', redirect: (context, state) => '/staff/${state.pathParameters['id']}', ), GoRoute( path: '/studio/:id/:_(.*)', redirect: (context, state) => '/studio/${state.pathParameters['id']}', ), GoRoute( path: '/user/:name/:_(.*)', redirect: (context, state) => '/user/${state.pathParameters['name']}', ), GoRoute( path: '/forum/thread/:_([^/]*)/comment/:id', redirect: (context, state) => '/comment/${state.pathParameters['id']}', ), GoRoute( path: '/forum/thread/:id', redirect: (context, state) => '/thread/${state.pathParameters['id']}', ), GoRoute(path: '/forum/:_([^/]*)', redirect: (context, state) => '/forum'), ]; return GoRouter( routes: routes, initialLocation: Routes.home(), errorBuilder: (context, state) => const NotFoundView(), ); } } String? _parseIdOr404(BuildContext context, GoRouterState state) => int.tryParse(state.pathParameters['id'] ?? '') == null ? Routes.notFound : null; class NotFoundView extends StatelessWidget { const NotFoundView(); @override Widget build(BuildContext context) { return Scaffold( appBar: const TopBar(title: 'Not Found'), body: Center( child: Column( mainAxisSize: .min, children: [ Text('404 Not Found', style: TextTheme.of(context).bodyMedium), TextButton(child: const Text('Go Home'), onPressed: () => context.go(Routes.home())), ], ), ), ); } } class _AuthView extends ConsumerStatefulWidget { const _AuthView(this.credentials); final (String token, int secondsUntilExpiration)? credentials; @override ConsumerState<_AuthView> createState() => __AuthViewState(); } class __AuthViewState extends ConsumerState<_AuthView> { @override void initState() { super.initState(); // On iOS the in app browser doesn't automatically close after login. closeInAppWebView().onError((_, _) {}); if (widget.credentials == null) { WidgetsBinding.instance.addPostFrameCallback((_) async { await ConfirmationDialog.show(context, title: 'Invalid credentials'); if (mounted) context.go(Routes.home()); }); } _attemptToFinishAccountSetup(); } @override void didUpdateWidget(covariant _AuthView oldWidget) { super.didUpdateWidget(oldWidget); if (widget.credentials?.$1 != oldWidget.credentials?.$1 || widget.credentials?.$2 != oldWidget.credentials?.$2) { _attemptToFinishAccountSetup(); } } @override Widget build(BuildContext context) { return Scaffold( body: const Center( child: Column( mainAxisSize: .min, children: [Text('Authenticating, please wait...'), SizedBox(height: 20), Loader()], ), ), ); } void _attemptToFinishAccountSetup() async { if (widget.credentials == null) { return; } final token = widget.credentials!.$1; final expiration = widget.credentials!.$2; final account = await ref.read(repositoryProvider.notifier).initAccount(token, expiration); if (account == null) { if (mounted) { await ConfirmationDialog.show(context, title: 'Failed to connect account'); if (mounted) context.go(Routes.home()); } return; } await ref.read(persistenceProvider.notifier).addAccount(account); if (mounted) context.go(Routes.home()); } } ================================================ FILE: lib/util/theming.dart ================================================ import 'dart:ui'; import 'package:flutter/material.dart'; enum FormFactor { phone, tablet } enum ThemeBase { navy('Navy', Color(0xFF45A0F2)), mint('Mint', Color(0xFF2AB8B8)), lavender('Lavender', Color(0xFFB4ABF5)), caramel('Caramel', Color(0xFFF78204)), forest('Forest', Color(0xFF00FFA9)), wine('Wine', Color(0xFF894771)), mustard('Mustard', Color(0xFFFFBF02)); const ThemeBase(this.title, this.seed); final String title; final Color seed; } class Theming extends ThemeExtension { const Theming({required this.formFactor, required this.rightButtonOrientation}); /// Pages should adapt their layouts, in consideration of the [formFactor]. final FormFactor formFactor; /// Determines whether FAB and prominent buttons should be on the right side, /// with lest important buttons on the left. /// This makes core actions more accessible. final bool rightButtonOrientation; static Theming of(BuildContext context) => Theme.of(context).extension() ?? const Theming(formFactor: .phone, rightButtonOrientation: true); @override ThemeExtension copyWith({FormFactor? formFactor, bool? rightButtonOrientation}) => Theming( formFactor: formFactor ?? this.formFactor, rightButtonOrientation: rightButtonOrientation ?? this.rightButtonOrientation, ); @override ThemeExtension lerp(covariant ThemeExtension? other, double t) => switch (other) { Theming _ => other, _ => this, }; static const windowWidthMedium = 600.0; static const windowWidthLarge = 840.0; static const offset = 10.0; static const minTapTarget = 48.0; static const normalTapTarget = 56.0; static const coverHtoWRatio = 1.53; static const fontBig = 18.0; static const fontMedium = 15.0; static const fontSmall = 13.0; static const iconBig = 25.0; static const iconSmall = 20.0; static const paddingAll = EdgeInsets.all(offset); static const radiusSmall = Radius.circular(12); static const radiusBig = Radius.circular(24); static const borderRadiusSmall = BorderRadius.all(radiusSmall); static const borderRadiusBig = BorderRadius.all(radiusBig); static final blurFilter = ImageFilter.blur(sigmaX: 5, sigmaY: 5); static const bouncyPhysics = AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()); static ThemeData generateThemeData(ColorScheme scheme) => ThemeData( fontFamily: 'Rubik', colorScheme: scheme, scaffoldBackgroundColor: scheme.surface, disabledColor: scheme.surface, unselectedWidgetColor: scheme.surface, highlightColor: Colors.transparent, cardTheme: const CardThemeData(margin: .all(0)), iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: iconBig), navigationBarTheme: NavigationBarThemeData( backgroundColor: scheme.surface.withAlpha(190), labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, ), navigationRailTheme: const NavigationRailThemeData( labelType: NavigationRailLabelType.all, groupAlignment: 0, ), chipTheme: ChipThemeData( labelStyle: TextStyle( color: scheme.onSecondaryContainer, fontVariations: const [FontVariation('wght', 400)], ), ), segmentedButtonTheme: const SegmentedButtonThemeData( style: ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), ), sliderTheme: const SliderThemeData( trackGap: 6, trackHeight: 16, trackShape: GappedSliderTrackShape(), thumbShape: HandleThumbShape(), thumbSize: WidgetStatePropertyAll(Size(4, 44)), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, iconColor: scheme.onPrimary, textStyle: const TextStyle( fontSize: fontMedium, fontVariations: [FontVariation('wght', 500)], ), ), ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( textStyle: const TextStyle( fontSize: fontMedium, fontVariations: [FontVariation('wght', 400)], ), ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( textStyle: const TextStyle( fontSize: fontMedium, fontVariations: [FontVariation('wght', 450)], ), ), ), listTileTheme: ListTileThemeData( contentPadding: const .symmetric(horizontal: offset), titleTextStyle: TextStyle( fontSize: fontMedium, color: scheme.onSurface, fontVariations: const [FontVariation('wght', 400)], ), subtitleTextStyle: TextStyle( fontSize: fontSmall, color: scheme.onSurfaceVariant, fontVariations: const [FontVariation('wght', 350)], ), ), textTheme: TextTheme( titleMedium: TextStyle( fontSize: fontBig, color: scheme.onSurface, fontVariations: const [FontVariation('wght', 450)], ), titleSmall: TextStyle( fontSize: fontMedium, color: scheme.onSurface, fontVariations: const [FontVariation('wght', 450)], ), bodyLarge: TextStyle( fontSize: fontBig, color: scheme.onSurface, fontVariations: const [FontVariation('wght', 400)], ), bodyMedium: TextStyle( fontSize: fontMedium, color: scheme.onSurface, fontVariations: const [FontVariation('wght', 400)], ), labelLarge: TextStyle( fontSize: fontMedium, color: scheme.onSurfaceVariant, fontVariations: const [FontVariation('wght', 400)], ), labelMedium: TextStyle( fontSize: fontMedium, color: scheme.onSurfaceVariant, fontVariations: const [FontVariation('wght', 400)], ), labelSmall: TextStyle( fontSize: fontSmall, color: scheme.onSurfaceVariant, fontVariations: const [FontVariation('wght', 350)], letterSpacing: 0.5, ), ), textSelectionTheme: TextSelectionThemeData( cursorColor: scheme.primary, selectionHandleColor: scheme.primary, selectionColor: scheme.primary.withAlpha(50), ), dividerTheme: const DividerThemeData(thickness: 1), dialogTheme: DialogThemeData( backgroundColor: scheme.surface, titleTextStyle: TextStyle( fontSize: fontMedium, color: scheme.onSurface, fontVariations: const [FontVariation('wght', 500)], ), contentTextStyle: TextStyle( fontSize: fontMedium, color: scheme.onSurface, fontVariations: const [FontVariation('wght', 400)], ), ), tooltipTheme: TooltipThemeData( padding: paddingAll, textStyle: TextStyle(color: scheme.onSurfaceVariant), decoration: BoxDecoration( color: scheme.surfaceContainerHighest, borderRadius: borderRadiusSmall, border: .all(color: scheme.outline), boxShadow: [BoxShadow(color: scheme.surface, blurRadius: 10)], ), ), scrollbarTheme: ScrollbarThemeData( interactive: true, radius: radiusSmall, thickness: .all(5), thumbColor: .all(scheme.primary), ), inputDecorationTheme: InputDecorationTheme( isDense: true, hintStyle: TextStyle( fontSize: fontMedium, color: scheme.onSurfaceVariant, fontVariations: const [FontVariation('wght', 400)], ), border: const OutlineInputBorder( borderRadius: borderRadiusSmall, borderSide: BorderSide.none, ), ), ); } ================================================ FILE: lib/util/tile_modelable.dart ================================================ /// A lot of models have commonly accessed elements /// that can be unified and used in agnostic views. abstract class TileModelable { int get tileId; String get tileTitle; String? get tileSubtitle; String get tileImageUrl; } ================================================ FILE: lib/widget/cached_image.dart ================================================ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; /// A custom cache manager is needed to define exact image cap and stale period. final _cacheManager = CacheManager( Config('imageCache', maxNrOfCacheObjects: 1000, stalePeriod: const Duration(days: 10)), ); /// Erases image cache. void clearImageCache() => _cacheManager.emptyCache(); /// A [CachedNetworkImage] wrapper that simplifies the interface /// and uses the custom cache manager, without exposing it. class CachedImage extends StatelessWidget { const CachedImage( this.imageUrl, { this.fit = BoxFit.cover, this.width = double.infinity, this.height = double.infinity, }); final String imageUrl; final BoxFit fit; final double? width; final double? height; @override Widget build(BuildContext context) { return CachedNetworkImage( imageUrl: imageUrl, fit: fit, width: width, height: height, cacheManager: _cacheManager, fadeInDuration: const Duration(milliseconds: 300), fadeOutDuration: const Duration(milliseconds: 300), errorWidget: (context, _, _) => IconButton( tooltip: 'Error', icon: const Icon(Icons.close_outlined), onPressed: () => SnackBarExtension.show(context, 'Failed to load image'), ), ); } } ================================================ FILE: lib/widget/dialogs.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/html_content.dart'; import 'package:vector_math/vector_math_64.dart' show Vector3; class TextInputDialog extends StatefulWidget { const TextInputDialog({required this.title, required this.initialValue, this.validator}); final String title; final String initialValue; final String? Function(String)? validator; @override State createState() => _TextInputDialogState(); } class _TextInputDialogState extends State { late final _textCtrl = TextEditingController(text: widget.initialValue); final _formKey = GlobalKey(); @override void dispose() { _textCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: Text(widget.title), content: Form( key: _formKey, child: TextFormField( autofocus: true, controller: _textCtrl, decoration: InputDecoration( isDense: true, hint: const Text('Enter'), hintStyle: TextStyle(color: ColorScheme.of(context).onSurfaceVariant), border: const OutlineInputBorder(borderRadius: Theming.borderRadiusSmall), ), autovalidateMode: AutovalidateMode.onUserInteraction, validator: (value) { final text = value?.trim() ?? ''; if (text.isEmpty) { return 'The field cannot be empty.'; } if (widget.validator != null) { return widget.validator!(text); } return null; }, ), ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), TextButton( onPressed: () { if (_formKey.currentState!.validate()) { Navigator.pop(context, _textCtrl.text.trim()); } }, child: const Text('Confirm'), ), ], ); } } // A basic container for a dialog. class DialogBox extends StatelessWidget { const DialogBox(this.child); final Widget child; @override Widget build(BuildContext context) { return Dialog( insetPadding: const .symmetric(horizontal: 30, vertical: 50), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 700, maxHeight: 600), child: child, ), ); } } class ConfirmationDialog extends StatelessWidget { const ConfirmationDialog._({ required this.title, required this.content, required this.primaryAction, required this.secondaryAction, }); final String title; final String? content; final String primaryAction; final String? secondaryAction; static Future show( BuildContext context, { required String title, String? content, String primaryAction = 'Ok', String? secondaryAction, void Function()? onConfirm, }) => showDialog( context: context, builder: (context) => ConfirmationDialog._( title: title, content: content, primaryAction: primaryAction, secondaryAction: secondaryAction, ), ).then((ok) => ok == true ? onConfirm?.call() : null); @override Widget build(BuildContext context) { return AlertDialog( title: Text(title), content: content != null ? Text(content!) : null, actions: [ if (secondaryAction != null) TextButton(child: Text(secondaryAction!), onPressed: () => Navigator.pop(context, false)), TextButton(child: Text(primaryAction), onPressed: () => Navigator.pop(context, true)), ], ); } } class ImageDialog extends StatefulWidget { const ImageDialog(this.url); final String url; @override State createState() => _ImageDialogState(); } class _ImageDialogState extends State with SingleTickerProviderStateMixin { final _transformCtrl = TransformationController(); late final AnimationController _animationCtrl; late final CurvedAnimation _curveWrapper; Animation? _animation; /// Last place the user double-tapped on. Offset? _lastOffset; @override void initState() { super.initState(); _animationCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 200)); _curveWrapper = CurvedAnimation(parent: _animationCtrl, curve: Curves.easeOutExpo); } @override void dispose() { _transformCtrl.dispose(); _animationCtrl.dispose(); super.dispose(); } void _updateState() => _transformCtrl.value = _animation!.value; void _endAnimation() { _animation?.removeListener(_updateState); _animation = null; _animationCtrl.reset(); } void _animateMatrixTo(Matrix4 goal) { _endAnimation(); _animation = Matrix4Tween(begin: _transformCtrl.value, end: goal).animate(_curveWrapper); _animation!.addListener(_updateState); _animationCtrl.forward(); } @override Widget build(BuildContext context) { return Dialog( insetPadding: .zero, backgroundColor: Colors.transparent, surfaceTintColor: Colors.transparent, child: GestureDetector( onDoubleTapDown: (details) => _lastOffset = details.localPosition, onDoubleTap: () { // If zoomed in, zoom out. if (_transformCtrl.value.getMaxScaleOnAxis() > 1) { _animateMatrixTo(Matrix4.identity()); return; } // Can't be null, but checking just in case. if (_lastOffset == null) return; // If zoomed out, zoom in towards the tapped spot. final zoomed = _transformCtrl.value.clone(); zoomed.translateByVector3(Vector3(-_lastOffset!.dx, -_lastOffset!.dy, 0)); zoomed.scaleByVector3(Vector3(2.0, 2.0, 1.0)); _animateMatrixTo(zoomed); }, child: InteractiveViewer( clipBehavior: Clip.none, transformationController: _transformCtrl, child: CachedImage(widget.url, fit: BoxFit.contain, width: null, height: null), ), ), ); } } class TextDialog extends StatelessWidget { const TextDialog({required this.title, required this.text}); final String title; final String text; @override Widget build(BuildContext context) => _DialogColumn(title: title, child: SelectableText(text)); } class HtmlDialog extends StatelessWidget { const HtmlDialog({required this.title, required this.text}); final String title; final String text; @override Widget build(BuildContext context) => _DialogColumn(title: title, child: HtmlContent(text)); } class _DialogColumn extends StatelessWidget { const _DialogColumn({required this.title, required this.child}); final String title; final Widget child; @override Widget build(BuildContext context) { return DialogBox( Padding( padding: const .symmetric(horizontal: 20), child: Column( crossAxisAlignment: .start, mainAxisSize: .min, children: [ Padding( padding: const .symmetric(vertical: Theming.offset), child: Text(title, style: TextTheme.of(context).bodyMedium), ), const Divider(height: 2, thickness: 2), Flexible( fit: FlexFit.loose, child: Scrollbar( child: SingleChildScrollView( padding: const .symmetric(vertical: Theming.offset), child: child, ), ), ), ], ), ), ); } } ================================================ FILE: lib/widget/grid/chip_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/util/theming.dart'; class ChipGrid extends StatelessWidget { const ChipGrid({ required this.title, required this.placeholder, required this.children, required this.onEdit, this.onClear, }); final String title; final String placeholder; final List children; final void Function() onEdit; final void Function()? onClear; @override Widget build(BuildContext context) { return Column( mainAxisSize: .min, children: [ Row( children: [ Text(title), const Spacer(), if (onClear != null && children.isNotEmpty) SizedBox( height: 35, child: IconButton( key: const ValueKey('Clear'), icon: const Icon(Ionicons.close_outline), tooltip: 'Clear', onPressed: onClear!, color: ColorScheme.of(context).onSurface, padding: const .symmetric(horizontal: Theming.offset), ), ), SizedBox( height: 35, child: IconButton( icon: const Icon(Ionicons.add_circle_outline), tooltip: 'Edit', onPressed: onEdit, color: ColorScheme.of(context).onSurface, padding: const .symmetric(horizontal: Theming.offset), ), ), ], ), children.isNotEmpty ? Wrap(spacing: 5, children: children) : SizedBox( height: Theming.minTapTarget, child: Center( child: Text('No $placeholder', style: TextTheme.of(context).labelMedium), ), ), ], ); } } ================================================ FILE: lib/widget/grid/dual_relation_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/util/tile_modelable.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; class DualRelationGrid extends StatelessWidget { const DualRelationGrid({ required this.items, required this.onTapPrimary, required this.onTapSecondary, required this.highContrast, }); final List<(TileModelable, TileModelable?)> items; final void Function(TileModelable item) onTapPrimary; final void Function(TileModelable item) onTapSecondary; final bool highContrast; @override Widget build(BuildContext context) { if (items.isEmpty) return const SliverToBoxAdapter(); final textTheme = TextTheme.of(context); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelSmallLineHeight = context.lineHeight(textTheme.labelSmall!); final tileHeight = bodyMediumLineHeight * 3 + labelSmallLineHeight * 2 + 13; final imageWidth = tileHeight / Theming.coverHtoWRatio; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 300, height: tileHeight), delegate: SliverChildBuilderDelegate( childCount: items.length, (context, i) => _Tile( primaryItem: items[i].$1, secondaryItem: items[i].$2, onTapPrimary: onTapPrimary, onTapSecondary: onTapSecondary, highContrast: highContrast, imageWidth: imageWidth, ), ), ); } } class _Tile extends StatelessWidget { const _Tile({ required this.primaryItem, required this.secondaryItem, required this.onTapPrimary, required this.onTapSecondary, required this.highContrast, required this.imageWidth, }); final TileModelable primaryItem; final TileModelable? secondaryItem; final void Function(TileModelable item) onTapPrimary; final void Function(TileModelable item) onTapSecondary; final bool highContrast; final double imageWidth; @override Widget build(BuildContext context) { late final Widget centerContent; if (secondaryItem != null) { centerContent = Column( crossAxisAlignment: .stretch, mainAxisAlignment: .spaceBetween, children: [ Flexible( child: GestureDetector( behavior: .opaque, onTap: () => onTapPrimary(primaryItem), child: Column( mainAxisSize: .min, crossAxisAlignment: .start, children: [ Flexible(child: Text(primaryItem.tileTitle, overflow: .ellipsis, maxLines: 2)), if (primaryItem.tileSubtitle != null) Text( primaryItem.tileSubtitle!, style: TextTheme.of(context).labelSmall, overflow: .ellipsis, maxLines: 1, ), ], ), ), ), const Divider(height: 3), GestureDetector( behavior: .opaque, onTap: () => onTapSecondary(secondaryItem!), child: Column( mainAxisSize: .min, crossAxisAlignment: .end, children: [ Flexible( child: Text( secondaryItem!.tileTitle, overflow: .ellipsis, textAlign: .end, maxLines: 1, ), ), if (secondaryItem!.tileSubtitle != null) Text( secondaryItem!.tileSubtitle!, style: TextTheme.of(context).labelSmall, overflow: .ellipsis, maxLines: 1, ), ], ), ), ], ); } else { centerContent = GestureDetector( behavior: .opaque, onTap: () => onTapPrimary(primaryItem), child: Column( mainAxisAlignment: .start, crossAxisAlignment: .start, children: [ Flexible(child: Text(primaryItem.tileTitle, overflow: .ellipsis, maxLines: 2)), if (primaryItem.tileSubtitle != null) Text( primaryItem.tileSubtitle!, style: TextTheme.of(context).labelSmall, overflow: .ellipsis, maxLines: 2, ), ], ), ); } return CardExtension.highContrast(highContrast)( child: Row( children: [ GestureDetector( behavior: .opaque, onTap: () => onTapPrimary(primaryItem), child: ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: CachedImage(primaryItem.tileImageUrl, width: imageWidth), ), ), Expanded( child: Padding( padding: const .symmetric(horizontal: Theming.offset, vertical: 5), child: centerContent, ), ), if (secondaryItem != null) GestureDetector( behavior: .opaque, key: ValueKey(secondaryItem!.tileId), onTap: () => onTapSecondary(secondaryItem!), child: ClipRRect( borderRadius: const BorderRadius.horizontal(right: Theming.radiusSmall), child: CachedImage(secondaryItem!.tileImageUrl, width: imageWidth), ), ), ], ), ); } } ================================================ FILE: lib/widget/grid/mono_relation_grid.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/util/tile_modelable.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; class MonoRelationGrid extends StatelessWidget { const MonoRelationGrid({required this.items, required this.onTap, required this.highContrast}); final List items; final void Function(TileModelable item) onTap; final bool highContrast; @override Widget build(BuildContext context) { if (items.isEmpty) return const SliverToBoxAdapter(); final textTheme = TextTheme.of(context); final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!); final labelSmallLineHeight = context.lineHeight(textTheme.labelSmall!); final tileHeight = bodyMediumLineHeight * 2 + labelSmallLineHeight * 2 + 10; final imageWidth = tileHeight / Theming.coverHtoWRatio; return SliverGrid( gridDelegate: SliverGridDelegateWithMinWidthAndFixedHeight(minWidth: 240, height: tileHeight), delegate: SliverChildBuilderDelegate( childCount: items.length, (context, i) => _Tile(item: items[i], onTap: onTap, highContrast: highContrast, imageWidth: imageWidth), ), ); } } class _Tile extends StatelessWidget { const _Tile({ required this.item, required this.onTap, required this.highContrast, required this.imageWidth, }); final TileModelable item; final void Function(TileModelable item) onTap; final bool highContrast; final double imageWidth; @override Widget build(BuildContext context) { return CardExtension.highContrast(highContrast)( child: InkWell( borderRadius: Theming.borderRadiusSmall, onTap: () => onTap(item), child: Row( children: [ ClipRRect( borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall), child: CachedImage(item.tileImageUrl, width: imageWidth), ), Expanded( child: Padding( padding: const .symmetric(horizontal: Theming.offset, vertical: 5), child: Column( mainAxisAlignment: .spaceEvenly, crossAxisAlignment: .start, children: [ Flexible(child: Text(item.tileTitle, overflow: .ellipsis, maxLines: 2)), if (item.tileSubtitle != null) Text( item.tileSubtitle!, style: TextTheme.of(context).labelSmall, overflow: .ellipsis, maxLines: 2, ), ], ), ), ), ], ), ), ); } } ================================================ FILE: lib/widget/grid/sliver_grid_delegates.dart ================================================ import 'package:flutter/rendering.dart'; import 'package:otraku/util/theming.dart'; /// Places as many items on the cross axis as possible, without making them /// narrower than [minWidth]. The item height is fixed. class SliverGridDelegateWithMinWidthAndFixedHeight extends SliverGridDelegate { const SliverGridDelegateWithMinWidthAndFixedHeight({ required this.minWidth, required this.height, this.mainAxisSpacing = Theming.offset, this.crossAxisSpacing = Theming.offset, }) : assert(minWidth > 0), assert(height > 0), assert(mainAxisSpacing >= 0), assert(crossAxisSpacing >= 0); final double minWidth; final double height; final double mainAxisSpacing; final double crossAxisSpacing; bool _debugAssertIsValid() { assert(minWidth > 0.0); assert(mainAxisSpacing >= 0.0); assert(crossAxisSpacing >= 0.0); assert(height > 0.0); return true; } @override SliverGridLayout getLayout(SliverConstraints constraints) { assert(_debugAssertIsValid()); int crossAxisCount = (constraints.crossAxisExtent + crossAxisSpacing) ~/ (minWidth + crossAxisSpacing); if (crossAxisCount < 1) crossAxisCount = 1; double usableCrossAxisExtent = constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1); if (usableCrossAxisExtent < 0.0) usableCrossAxisExtent = 0.0; final crossAxisExtent = usableCrossAxisExtent / crossAxisCount; return SliverGridRegularTileLayout( crossAxisCount: crossAxisCount, mainAxisStride: height + mainAxisSpacing, crossAxisStride: crossAxisExtent + crossAxisSpacing, childMainAxisExtent: height, childCrossAxisExtent: crossAxisExtent, reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), ); } @override bool shouldRelayout(SliverGridDelegateWithMinWidthAndFixedHeight oldDelegate) => oldDelegate.height != height || oldDelegate.minWidth != minWidth || oldDelegate.mainAxisSpacing != mainAxisSpacing || oldDelegate.crossAxisSpacing != crossAxisSpacing; } /// Places as many items on the cross axis as possible, without making them /// narrower than [minWidth]. The item height is equal to the item width, /// multiplied by [rawHWRatio] and combined with [extraHeight]. class SliverGridDelegateWithMinWidthAndExtraHeight extends SliverGridDelegate { const SliverGridDelegateWithMinWidthAndExtraHeight({ required this.minWidth, this.mainAxisSpacing = Theming.offset, this.crossAxisSpacing = Theming.offset, this.extraHeight = 0.0, this.rawHWRatio = 1.0, }) : assert(minWidth >= 0), assert(mainAxisSpacing >= 0), assert(crossAxisSpacing >= 0), assert(extraHeight >= 0), assert(rawHWRatio > 0); final double minWidth; final double mainAxisSpacing; final double crossAxisSpacing; final double extraHeight; final double rawHWRatio; bool _debugAssertIsValid() { assert(minWidth > 0.0); assert(mainAxisSpacing >= 0.0); assert(crossAxisSpacing >= 0.0); assert(extraHeight >= 0.0); assert(rawHWRatio > 0.0); return true; } @override SliverGridLayout getLayout(SliverConstraints constraints) { assert(_debugAssertIsValid()); int crossAxisCount = (constraints.crossAxisExtent + crossAxisSpacing) ~/ (minWidth + crossAxisSpacing); if (crossAxisCount < 1) crossAxisCount = 1; double usableCrossAxisExtent = constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1); if (usableCrossAxisExtent < 0.0) usableCrossAxisExtent = 0.0; final crossAxisExtent = usableCrossAxisExtent / crossAxisCount; final mainAxisExtent = crossAxisExtent * rawHWRatio + extraHeight; return SliverGridRegularTileLayout( crossAxisCount: crossAxisCount, mainAxisStride: mainAxisExtent + mainAxisSpacing, crossAxisStride: crossAxisExtent + crossAxisSpacing, childMainAxisExtent: mainAxisExtent, childCrossAxisExtent: crossAxisExtent, reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), ); } @override bool shouldRelayout(SliverGridDelegateWithMinWidthAndExtraHeight oldDelegate) => oldDelegate.minWidth != minWidth || oldDelegate.mainAxisSpacing != mainAxisSpacing || oldDelegate.crossAxisSpacing != crossAxisSpacing || oldDelegate.extraHeight != extraHeight || oldDelegate.rawHWRatio != rawHWRatio; } ================================================ FILE: lib/widget/html_content.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/util/routes.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/loaders.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/sheets.dart'; class HtmlContent extends StatelessWidget { const HtmlContent(this.text, {this.renderMode = RenderMode.column}); final String text; final RenderMode renderMode; @override Widget build(BuildContext context) { return HtmlWidget( text, renderMode: renderMode, textStyle: TextTheme.of(context).bodyMedium, onTapUrl: (url) { for (final matcher in _routeMatchers.entries) { final match = matcher.key.firstMatch(url)?.group(1); if (match != null) { context.push(matcher.value(match)); return true; } } return SnackBarExtension.launch(context, url); }, onTapImage: (metadata) { final source = metadata.sources.firstOrNull?.url; if (source != null) { showDialog(context: context, builder: (context) => ImageDialog(source)); } }, onLoadingBuilder: (_, _, _) => const Center(child: Loader()), onErrorBuilder: (_, element, err) => Center( child: IconButton( tooltip: 'Error', icon: const Icon(Icons.close_outlined), onPressed: () => SnackBarExtension.show(context, 'Failed to load element ${element.localName}'), ), ), customStylesBuilder: (element) { return switch (element.localName) { 'br' => const {'line-height': '15px'}, 'i' || 'em' => const {'font-style': 'italic'}, 'b' || 'strong' => const {'font-weight': '500'}, 'h1' => const {'font-size': '20px', 'font-weight': '400'}, 'h2' => const {'font-size': '18px', 'font-weight': '400'}, 'h3' => const {'font-size': '17px', 'font-weight': '400'}, 'h5' => const {'font-size': '13px', 'font-weight': '400'}, 'h4' || 'h6' => const {'font-weight': '400'}, 'a' => const {'text-decoration': 'none'}, 'img' => element.attributes['width'] != null ? {'width': element.attributes['width']!} : null, _ => const {}, }; }, customWidgetBuilder: (element) { if (element.localName == 'hr') { return Container( height: 5, width: double.infinity, margin: const .symmetric(vertical: 5), decoration: BoxDecoration( color: ColorScheme.of(context).surfaceContainerHighest, borderRadius: Theming.borderRadiusSmall, ), ); } if (element.localName == 'youtube') { return GestureDetector( onTap: () => SnackBarExtension.launch(context, 'https://youtube.com/watch?v=${element.text}'), child: Stack( alignment: Alignment.center, children: [ ConstrainedBox( constraints: const BoxConstraints(maxWidth: 240, maxHeight: 135), child: CachedImage('https://img.youtube.com/vi/${element.text}/0.jpg'), ), const Icon(Ionicons.logo_youtube, color: Color(0xFFFF0000), size: 40), ], ), ); } if (element.localName == 'video') { final source = element.children.firstWhere((e) => e.localName == 'source'); final url = source.attributes['src'] ?? ''; return SizedBox( width: double.infinity, child: Center( child: IconButton( tooltip: 'WebM Video', icon: const Icon(Ionicons.videocam, size: 50), onPressed: () => showSheet(context, SimpleSheet.link(context, url)), ), ), ); } return null; }, ); } } final _routeMatchers = { RegExp(r'anilist.co\/(?:anime|manga)\/(\d+)'): (String id) => Routes.media(int.parse(id)), RegExp(r'anilist.co\/user\/([A-Za-z0-9]+)'): (String name) => Routes.userByName(name), RegExp(r'anilist.co\/character\/(\d+)'): (String id) => Routes.character(int.parse(id)), RegExp(r'anilist.co\/staff\/(\d+)'): (String id) => Routes.staff(int.parse(id)), RegExp(r'anilist.co\/studio\/(\d+)'): (String id) => Routes.studio(int.parse(id)), RegExp(r'anilist.co\/review\/(\d+)'): (String id) => Routes.review(int.parse(id)), RegExp(r'anilist.co\/activity\/(\d+)'): (String id) => Routes.activity(int.parse(id)), }; ================================================ FILE: lib/widget/input/chip_selector.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/filter_chip_extension.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/shadowed_overflow_list.dart'; import 'package:otraku/feature/media/media_models.dart'; /// A horizontal list of chips, where only one can be selected at a time. class ChipSelector extends StatefulWidget { const ChipSelector._({ required this.title, required this.items, required this.value, required this.onChanged, required this.mustHaveSelected, required this.highContrast, }); /// Allows for nothing to be selected. factory ChipSelector({ required String title, required List<(String label, T value)> items, required T? value, required void Function(T?) onChanged, required bool highContrast, }) => ChipSelector._( title: title, items: items, value: value, onChanged: onChanged, highContrast: highContrast, mustHaveSelected: false, ); /// Requires an option to be selected. [onChanged] will never receive `null`. factory ChipSelector.ensureSelected({ required String title, required List<(String label, T value)> items, required T value, required void Function(T) onChanged, required bool highContrast, }) => ChipSelector._( title: title, items: items, value: value, onChanged: (v) => onChanged(v ?? value), highContrast: highContrast, mustHaveSelected: true, ); final String title; final List<(String label, T value)> items; final T? value; final void Function(T?) onChanged; final bool mustHaveSelected; final bool highContrast; @override State> createState() => _ChipSelectorState(); } class _ChipSelectorState extends State> { late T? _value = widget.value; @override void didUpdateWidget(covariant ChipSelector oldWidget) { super.didUpdateWidget(oldWidget); _value = widget.value; } @override Widget build(BuildContext context) { return _ChipSelector( title: widget.title, length: widget.items.length, itemBuilder: (context, i) { final (label, value) = widget.items[i]; return FilterChipExtension.highContrast(widget.highContrast)( label: Text(label), selected: value == _value, onSelected: (selected) { // Should not pass `null` if [widget.mustHaveSelected]. if (value == _value && widget.mustHaveSelected) return; setState(() => selected ? _value = value : _value = null); widget.onChanged(_value); }, ); }, ); } } /// A horizontal list of chips, where zero or more are selected. /// Note: [values] are mutated directly. class ChipMultiSelector extends StatefulWidget { const ChipMultiSelector({ required this.title, required this.items, required this.values, required this.highContrast, }); final String title; final List<(String label, T value)> items; final List values; final bool highContrast; @override State> createState() => _ChipMultiSelectorState(); } class _ChipMultiSelectorState extends State> { @override Widget build(BuildContext context) { return _ChipSelector( title: widget.title, length: widget.items.length, itemBuilder: (context, i) { final (label, value) = widget.items[i]; return FilterChipExtension.highContrast(widget.highContrast)( label: Text(label), selected: widget.values.contains(value), onSelected: (isSelected) { setState(() => isSelected ? widget.values.add(value) : widget.values.remove(value)); }, ); }, ); } } class EntrySortChipSelector extends StatefulWidget { const EntrySortChipSelector({ required this.title, required this.value, required this.onChanged, required this.highContrast, }); final String title; final EntrySort value; final void Function(EntrySort) onChanged; final bool highContrast; @override State createState() => _EntrySortChipSelectorState(); } class _EntrySortChipSelectorState extends State { late var _value = widget.value; final _labels = []; @override void initState() { super.initState(); for (int i = 0; i < EntrySort.values.length; i += 2) { _labels.add(EntrySort.values[i].label); } } @override void didUpdateWidget(covariant EntrySortChipSelector oldWidget) { super.didUpdateWidget(oldWidget); _value = widget.value; } @override Widget build(BuildContext context) { final unorderedValue = _value.index ~/ 2; final isDescending = _value.index % 2 != 0; final colorScheme = ColorScheme.of(context); return _ChipSelector( title: widget.title, length: _labels.length, itemBuilder: (context, index) => FilterChipExtension.highContrast(widget.highContrast)( label: Text(_labels[index]), showCheckmark: false, avatar: unorderedValue == index ? Icon( isDescending ? Icons.arrow_downward_rounded : Icons.arrow_upward_rounded, color: colorScheme.onPrimaryContainer, ) : null, selected: unorderedValue == index, onSelected: (_) { setState(() { int i = index * 2; if (unorderedValue == index) { if (!isDescending) i++; } else { if (isDescending) i++; } _value = EntrySort.values.elementAt(i); }); widget.onChanged(_value); }, ), ); } } class _ChipSelector extends StatelessWidget { const _ChipSelector({required this.title, required this.length, required this.itemBuilder}); final String title; final int length; final Widget Function(BuildContext, int) itemBuilder; @override Widget build(BuildContext context) { return Column( mainAxisSize: .min, crossAxisAlignment: .start, children: [ Padding( padding: const .only( top: Theming.offset / 2, bottom: Theming.offset / 2, right: Theming.offset, ), child: Text(title), ), SizedBox( height: 40, child: ShadowedOverflowList(itemCount: length, itemBuilder: itemBuilder), ), ], ); } } ================================================ FILE: lib/widget/input/date_field.dart ================================================ import 'package:flutter/material.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/util/theming.dart'; class DateField extends StatefulWidget { const DateField({required this.label, required this.value, required this.onChanged}); final String label; final DateTime? value; final Function(DateTime?) onChanged; @override State createState() => _DateFieldState(); } class _DateFieldState extends State { late DateTime? _value = widget.value; late final _ctrl = TextEditingController(text: _value?.formattedDate ?? ''); @override void didUpdateWidget(covariant DateField oldWidget) { super.didUpdateWidget(oldWidget); _value = widget.value; final text = _value?.formattedDate ?? ''; if (_ctrl.text != text) _ctrl.text = text; } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return TextField( readOnly: true, controller: _ctrl, textAlign: .center, style: TextTheme.of(context).bodyMedium, onTap: () => showDatePicker( context: context, initialDate: _value ?? DateTime.now(), firstDate: DateTime(1920), lastDate: DateTime.now(), errorInvalidText: 'Enter date in valid range', errorFormatText: 'Enter valid date', confirmText: 'Done', cancelText: 'Cancel', fieldLabelText: '', helpText: '', ).then((pickedDate) { if (pickedDate == null) return; _value = pickedDate; _ctrl.text = _value?.formattedDate ?? ''; widget.onChanged(pickedDate); }), decoration: InputDecoration( labelText: widget.label, labelStyle: TextTheme.of(context).bodyMedium, border: const OutlineInputBorder(), suffixIcon: Semantics( button: true, child: Material( color: Colors.transparent, child: InkResponse( radius: Theming.radiusSmall.x, child: const Tooltip(message: 'Clear', child: Icon(Ionicons.close_outline)), onTap: () { _ctrl.text = ''; widget.onChanged(null); }, ), ), ), ), ); } } ================================================ FILE: lib/widget/input/note_label.dart ================================================ import 'package:flutter/material.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/dialogs.dart'; class NotesLabel extends StatelessWidget { const NotesLabel(this.notes); final String notes; @override Widget build(BuildContext context) { if (notes.isEmpty) return const SizedBox(); return SizedBox( height: 35, child: Tooltip( message: 'Comment', child: InkResponse( radius: Theming.radiusSmall.x, child: const Icon(Ionicons.chatbox, size: Theming.iconSmall), onTap: () => showDialog( context: context, builder: (context) => TextDialog(title: 'Comment', text: notes), ), ), ), ); } } ================================================ FILE: lib/widget/input/number_field.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:otraku/util/theming.dart'; class NumberField extends StatefulWidget { const NumberField._({ required this.label, required this.value, required this.minValue, required this.stepValue, required this.maxValue, required this.onChanged, required this.isDecimal, }); factory NumberField({ required String label, required void Function(int) onChanged, int value = 0, int minValue = 0, int? maxValue, }) => NumberField._( label: label, value: value, minValue: minValue, stepValue: 1, maxValue: maxValue, onChanged: (n) => onChanged(n.toInt()), isDecimal: false, ); factory NumberField.decimal({ required String label, required void Function(double) onChanged, double value = 0.0, double minValue = 0.0, double? maxValue, }) => NumberField._( label: label, value: value, minValue: minValue, stepValue: 0.5, maxValue: maxValue, onChanged: (n) => onChanged((n * 10).round() / 10), isDecimal: true, ); final String label; final num value; final num minValue; final num stepValue; final num? maxValue; final void Function(num) onChanged; final bool isDecimal; @override State createState() => _NumberFieldState(); } class _NumberFieldState extends State { late final _ctrl = TextEditingController(text: widget.value.toString()); String? _error; @override void didUpdateWidget(covariant NumberField oldWidget) { super.didUpdateWidget(oldWidget); final text = widget.value.toString(); if (text != _ctrl.text) { _ctrl.value = TextEditingValue( text: text, selection: TextSelection(baseOffset: text.length, extentOffset: text.length), ); } } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return TextField( controller: _ctrl, onChanged: _validateInput, textAlign: .center, style: TextTheme.of(context).bodyMedium, keyboardType: TextInputType.numberWithOptions(decimal: widget.isDecimal), inputFormatters: [ FilteringTextInputFormatter.allow( widget.isDecimal ? RegExp(r'^\d*\.?\d?$') : RegExp(r'\d*'), ), ], decoration: InputDecoration( labelText: widget.label, labelStyle: TextTheme.of(context).bodyMedium, errorText: _error, border: const OutlineInputBorder(), prefixIcon: Semantics( button: true, child: Material( color: Colors.transparent, child: InkResponse( onTap: () => _validateInput(_ctrl.text, -widget.stepValue), radius: Theming.radiusSmall.x, child: Tooltip( message: 'Decrement', onTriggered: () => _validateInput(widget.minValue.toString(), 0), child: const Icon(Icons.remove), ), ), ), ), suffixIcon: Semantics( button: true, child: Material( color: Colors.transparent, child: InkResponse( onTap: () => _validateInput(_ctrl.text, widget.stepValue), radius: Theming.radiusSmall.x, child: Tooltip( message: 'Increment', onTriggered: () { if (widget.maxValue == null) return; _validateInput(widget.maxValue.toString(), 0); }, child: const Icon(Icons.add), ), ), ), ), ), ); } void _validateInput(String value, [num? add]) { if (value.isEmpty) return; num number = num.parse(value); if (add != null) number += add; // The value is allowed to go out of bounds while editing, // but it should not affect the real state. if (number < widget.minValue || widget.maxValue != null && number > widget.maxValue!) { // Buttons can't make the field invalid, but manual edits can. if (_error == null && add == null) { setState( () => number < widget.minValue ? _error = 'Minimum ${widget.minValue}' : _error = 'Maximum ${widget.maxValue}', ); } return; } if (_error != null) setState(() => _error = null); widget.onChanged(number); // If the field was changed manually, it shouldn't erase an unfinished edit. if (add == null) return; final text = number.toString(); _ctrl.value = _ctrl.value.copyWith( text: text, selection: TextSelection(baseOffset: text.length, extentOffset: text.length), composing: TextRange.empty, ); } } ================================================ FILE: lib/widget/input/pill_selector.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/util/theming.dart'; class PillSelector extends StatelessWidget { const PillSelector({ required this.selected, required this.items, required this.onTap, this.maxWidth = double.infinity, this.shrinkWrap = false, this.scrollCtrl, }); final int? selected; final List items; final void Function(int) onTap; final double maxWidth; final bool shrinkWrap; final ScrollController? scrollCtrl; /// Approximation for a needed base height to display its contents. /// Can be used to calculate the initial size of sheets. static double expectedMinHeight(int itemCount) => (Theming.minTapTarget + Theming.offset / 2) * itemCount + Theming.offset * 2; @override Widget build(BuildContext context) { return ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: ListView.separated( controller: scrollCtrl, shrinkWrap: shrinkWrap, padding: MediaQuery.paddingOf(context).add(Theming.paddingAll), itemCount: items.length, separatorBuilder: (context, _) => const SizedBox(height: Theming.offset / 2), itemBuilder: (context, i) => Material( shape: const StadiumBorder(), color: i == selected ? ColorScheme.of(context).secondaryContainer : null, child: InkWell( customBorder: const StadiumBorder(), onTap: () => onTap(i), child: ConstrainedBox( constraints: const BoxConstraints(minHeight: Theming.minTapTarget), child: Padding( padding: const .symmetric( horizontal: Theming.offset * 1.5, vertical: Theming.offset * 0.5, ), child: Align(alignment: Alignment.centerLeft, child: items[i]), ), ), ), ), ), ); } } ================================================ FILE: lib/widget/input/score_label.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/feature/media/media_models.dart'; import 'package:otraku/util/theming.dart'; class ScoreLabel extends StatelessWidget { const ScoreLabel(this.score, this.scoreFormat); final double score; final ScoreFormat scoreFormat; @override Widget build(BuildContext context) { if (score == 0) return const SizedBox(); Widget content; switch (scoreFormat) { case .point3: if (score == 3) { content = const Icon(Icons.sentiment_very_satisfied, size: Theming.iconSmall); } else if (score == 2) { content = const Icon(Icons.sentiment_neutral, size: Theming.iconSmall); } else { content = const Icon(Icons.sentiment_very_dissatisfied, size: Theming.iconSmall); } case .point5: content = Row( mainAxisSize: .min, spacing: 3, children: [ Text(score.toStringAsFixed(0), style: TextTheme.of(context).labelSmall), const Icon(Icons.star_rounded, size: Theming.iconSmall), ], ); case .point10Decimal: content = Row( mainAxisSize: .min, spacing: 3, children: [ const Icon(Icons.star_half_rounded, size: Theming.iconSmall), Text(score.toStringAsFixed(1), style: TextTheme.of(context).labelSmall), ], ); default: content = Row( mainAxisSize: .min, spacing: 3, children: [ const Icon(Icons.star_half_rounded, size: Theming.iconSmall), Text(score.toStringAsFixed(0), style: TextTheme.of(context).labelSmall), ], ); } return Tooltip(message: 'Score', child: content); } } ================================================ FILE: lib/widget/input/search_field.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/util/debounce.dart'; class SearchField extends StatefulWidget { const SearchField({ required this.value, required this.hint, required this.onChanged, this.focusNode, this.debounce, }); final String value; final String hint; final void Function(String) onChanged; final FocusNode? focusNode; final Debounce? debounce; @override State createState() => _SearchFieldState(); } class _SearchFieldState extends State { late final _ctrl = TextEditingController(text: widget.value); @override void didUpdateWidget(covariant SearchField oldWidget) { super.didUpdateWidget(oldWidget); if (_ctrl.text != widget.value) _ctrl.text = widget.value; } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Semantics( label: 'Search', child: TextField( controller: _ctrl, focusNode: widget.focusNode, style: TextTheme.of(context).bodyMedium, onChanged: (val) { if (val.isEmpty) { widget.debounce?.cancel(); widget.onChanged(''); return; } if (widget.debounce != null) { widget.debounce!.run(() => widget.onChanged(val)); } else { widget.onChanged(val); } }, decoration: InputDecoration( isDense: false, hintText: widget.hint, filled: true, fillColor: ColorScheme.of(context).surfaceContainerHighest, contentPadding: const .only(left: 15), constraints: const BoxConstraints(minHeight: 35, maxHeight: 40), suffixIcon: _ctrl.text.isNotEmpty ? IconButton( tooltip: 'Clear', iconSize: Theming.iconSmall, icon: const Icon(Icons.close_rounded), color: ColorScheme.of(context).onSurface, padding: const .all(0), onPressed: () { _ctrl.clear(); widget.debounce?.cancel(); widget.onChanged(''); }, ) : null, ), ), ); } } ================================================ FILE: lib/widget/input/stateful_tiles.dart ================================================ import 'package:flutter/material.dart'; /// A wrapper around [SwitchListTile.adaptive], which handles state. class StatefulSwitchListTile extends StatefulWidget { const StatefulSwitchListTile({ required this.title, required this.value, required this.onChanged, this.subtitle, }); final Widget title; final Widget? subtitle; final bool value; final void Function(bool) onChanged; @override State createState() => _StatefulSwitchListTileState(); } class _StatefulSwitchListTileState extends State { late bool _value = widget.value; @override void didUpdateWidget(covariant StatefulSwitchListTile oldWidget) { super.didUpdateWidget(oldWidget); _value = widget.value; } @override Widget build(BuildContext context) { return SwitchListTile.adaptive( // The active color needs to be overriden, because // the cupertino selected state won't pick it up otherwise. activeTrackColor: ColorScheme.of(context).primary, title: widget.title, subtitle: widget.subtitle, value: _value, onChanged: (v) { setState(() => _value = v); widget.onChanged(v); }, ); } } /// A wrapper around [CheckboxListTile.adaptive], which handles state. class StatefulCheckboxListTile extends StatefulWidget { const StatefulCheckboxListTile({ required this.value, required this.onChanged, this.tristate = false, this.title, }); final bool? value; final void Function(bool?) onChanged; final Widget? title; final bool tristate; @override State createState() => _StatefulCheckboxListTileState(); } class _StatefulCheckboxListTileState extends State { late bool? _value = widget.value; @override void didUpdateWidget(covariant StatefulCheckboxListTile oldWidget) { super.didUpdateWidget(oldWidget); _value = widget.value; } @override Widget build(BuildContext context) { return CheckboxListTile.adaptive( // The active color needs to be overriden, because // the cupertino selected state won't pick it up otherwise. activeColor: ColorScheme.of(context).primary, title: widget.title, tristate: widget.tristate, value: _value, onChanged: (v) { setState(() => _value = v); widget.onChanged(v); }, ); } } class StatefulSegmentedButton extends StatefulWidget { const StatefulSegmentedButton({ super.key, required this.value, required this.onChanged, required this.segments, }); final T value; final void Function(T) onChanged; final List> segments; @override State> createState() => _StatefulSegmentedButtonState(); } class _StatefulSegmentedButtonState extends State> { late var _value = widget.value; @override void didUpdateWidget(covariant StatefulSegmentedButton oldWidget) { super.didUpdateWidget(oldWidget); _value = widget.value; } @override Widget build(BuildContext context) { return SegmentedButton( selected: {_value}, segments: widget.segments, onSelectionChanged: (value) { setState(() => _value = value.first); widget.onChanged(_value); }, ); } } ================================================ FILE: lib/widget/input/year_range_picker.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:otraku/widget/input/number_field.dart'; const _minYear = 1917; class YearRangePicker extends StatefulWidget { const YearRangePicker({ required this.title, required this.from, required this.to, required this.onChanged, }); final String title; final int? from; final int? to; final void Function(int?, int?) onChanged; @override State createState() => _YearRangePickerState(); } class _YearRangePickerState extends State { late int _maxYear; late int _from; late int _to; @override void initState() { super.initState(); _init(); } @override void didUpdateWidget(covariant YearRangePicker oldWidget) { super.didUpdateWidget(oldWidget); _init(); } void _init() { _maxYear = DateTime.now().year + 1; _from = widget.from ?? _minYear; _to = widget.to ?? _maxYear; if (_from < _minYear) _from = _minYear; if (_to > _maxYear) _to = _maxYear; if (_from > _to) _from = _to; } @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: NumberField( label: 'Release Start', value: _from, minValue: _minYear, maxValue: _maxYear, onChanged: (from) { setState(() { _from = from; if (_to < _from) _to = _from; }); _from > _minYear || _to < _maxYear ? widget.onChanged(_from, _to) : widget.onChanged(null, null); }, ), ), const SizedBox(width: 10), Expanded( child: NumberField( label: 'Release End', value: _to, minValue: _minYear, maxValue: _maxYear, onChanged: (to) { setState(() { _to = to; if (_from > _to) _from = _to; }); _from > _minYear || _to < _maxYear ? widget.onChanged(_from, _to) : widget.onChanged(null, null); }, ), ), ], ); } } ================================================ FILE: lib/widget/layout/adaptive_scaffold.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/layout/hiding_floating_action_button.dart'; import 'package:otraku/widget/layout/navigation_tool.dart'; class AdaptiveScaffold extends StatelessWidget { const AdaptiveScaffold({ required this.child, this.topBar, this.floatingAction, this.navigationConfig, this.bottomBar, this.sheetMode = false, }) : assert( navigationConfig == null || bottomBar == null, 'Cannot have both a navigation bar and a custom bottom bar', ); final Widget child; final PreferredSizeWidget? topBar; final HidingFloatingActionButton? floatingAction; final NavigationConfig? navigationConfig; final Widget? bottomBar; final bool sheetMode; @override Widget build(BuildContext context) { final theming = Theming.of(context); Color? backgroundColor; bool? resizeToAvoidBottomInset; if (sheetMode) { backgroundColor = Colors.transparent; resizeToAvoidBottomInset = false; } var startFabLocation = _StartFloatFabLocation.withoutOffset; const endFabLocation = FloatingActionButtonLocation.endFloat; var effectiveChild = child; var effectiveBottomBar = bottomBar; if (navigationConfig != null) { switch (theming.formFactor) { case .phone: effectiveBottomBar = BottomNavigation( selected: navigationConfig!.selected, items: navigationConfig!.items, onChanged: navigationConfig!.onChanged, onSame: navigationConfig!.onSame, ); case .tablet: final sideNavigation = SideNavigation( selected: navigationConfig!.selected, items: navigationConfig!.items, onChanged: navigationConfig!.onChanged, onSame: navigationConfig!.onSame, ); startFabLocation = _StartFloatFabLocation.withOffset; effectiveChild = Expanded(child: effectiveChild); effectiveChild = Row( children: Directionality.of(context) == TextDirection.ltr ? [sideNavigation, effectiveChild] : [effectiveChild, sideNavigation], ); } } return SafeArea( top: false, bottom: false, child: Scaffold( extendBody: true, extendBodyBehindAppBar: true, backgroundColor: backgroundColor, resizeToAvoidBottomInset: resizeToAvoidBottomInset, appBar: topBar, bottomNavigationBar: effectiveBottomBar, floatingActionButton: floatingAction, floatingActionButtonLocation: theming.rightButtonOrientation ? endFabLocation : startFabLocation, body: effectiveChild, ), ); } } /// A configuration that can be shared /// between bottom navigation bars and navigation rails. class NavigationConfig { const NavigationConfig({ required this.selected, required this.items, required this.onChanged, required this.onSame, }); final int selected; final Map items; final void Function(int) onChanged; final void Function(int) onSame; } class _StartFloatFabLocation extends StandardFabLocation with FabStartOffsetX, FabFloatOffsetY { const _StartFloatFabLocation(this.offset); static const withOffset = _StartFloatFabLocation(Theming.normalTapTarget * 1.5); static const withoutOffset = _StartFloatFabLocation(0); final double offset; @override double getOffsetX(ScaffoldPrelayoutGeometry scaffoldGeometry, double adjustment) { return switch (scaffoldGeometry.textDirection) { TextDirection.rtl => super.getOffsetX(scaffoldGeometry, adjustment + offset), TextDirection.ltr => super.getOffsetX(scaffoldGeometry, adjustment - offset), }; } @override String toString() => 'FloatingActionButtonLocation.startFloatWithOffset'; } ================================================ FILE: lib/widget/layout/constrained_view.dart ================================================ import 'package:flutter/widgets.dart'; import 'package:otraku/util/theming.dart'; /// Horizontally constrains [child] in the center. class ConstrainedView extends StatelessWidget { const ConstrainedView({required this.child, this.padded = true}); final Widget child; final bool padded; @override Widget build(BuildContext context) { return Center( child: Padding( padding: padded ? const .symmetric(horizontal: Theming.offset) : .zero, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: Theming.windowWidthMedium), child: child, ), ), ); } } /// An alternative to [ConstrainedView] for Sliver views. class SliverConstrainedView extends StatelessWidget { const SliverConstrainedView({required this.sliver}); final Widget sliver; @override Widget build(BuildContext context) { return SliverLayoutBuilder( builder: (context, constraints) { final side = (constraints.crossAxisExtent - Theming.windowWidthMedium) / 2; return SliverPadding( padding: .symmetric(horizontal: side < Theming.offset ? Theming.offset : side), sliver: sliver, ); }, ); } } ================================================ FILE: lib/widget/layout/content_header.dart ================================================ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/cached_image.dart'; import 'package:otraku/widget/dialogs.dart'; import 'package:otraku/widget/sheets.dart'; class CustomContentHeader extends StatelessWidget { const CustomContentHeader({ required this.title, required this.content, required this.siteUrl, this.bannerUrl, this.trailingTopButtons = const [], this.tabBarConfig, }); final String? title; final PreferredSizeWidget content; final String? siteUrl; final String? bannerUrl; final List trailingTopButtons; final TabBarConfig? tabBarConfig; @override Widget build(BuildContext context) { return SliverPersistentHeader( pinned: true, delegate: _Delegate( content: content, title: title, siteUrl: siteUrl, trailingTopButtons: trailingTopButtons, bannerUrl: bannerUrl, tabBarConfig: tabBarConfig, topPadding: MediaQuery.paddingOf(context).top, ), ); } } class ContentHeader extends StatelessWidget { const ContentHeader({ required this.imageUrl, required this.imageHeroTag, required this.imageHeightToWidthRatio, required this.title, required this.siteUrl, this.imageLargeUrl, this.imageFit = BoxFit.cover, this.trailingTopButtons = const [], this.details = const [], this.bannerUrl, this.tabBarConfig, }); final String? imageUrl; final String? imageLargeUrl; final Object imageHeroTag; final double imageHeightToWidthRatio; final BoxFit imageFit; final String? title; final List details; final List trailingTopButtons; final String? siteUrl; final String? bannerUrl; final TabBarConfig? tabBarConfig; @override Widget build(BuildContext context) { final imageWidth = ((MediaQuery.sizeOf(context).width - Theming.offset * 3) / 2.0).clamp( 0.0, 100.0, ); final imageHeight = imageWidth * imageHeightToWidthRatio; final content = _ImageContent( imageWidth: imageWidth, imageHeight: imageHeight, imageHeroTag: imageHeroTag, imageUrl: imageUrl, imageLargeUrl: imageLargeUrl, imageFit: imageFit, title: title, details: details, ); return SliverPersistentHeader( pinned: true, delegate: _Delegate( content: content, title: title, siteUrl: siteUrl, trailingTopButtons: trailingTopButtons, bannerUrl: bannerUrl, tabBarConfig: tabBarConfig, topPadding: MediaQuery.paddingOf(context).top, ), ); } } typedef TabBarConfig = ({TabController tabCtrl, List tabs, void Function() scrollToTop}); class _ImageContent extends StatelessWidget implements PreferredSizeWidget { const _ImageContent({ required this.imageWidth, required this.imageHeight, required this.imageHeroTag, required this.imageUrl, required this.imageLargeUrl, required this.imageFit, required this.title, required this.details, }); final double imageWidth; final double imageHeight; final Object imageHeroTag; final String? imageUrl; final String? imageLargeUrl; final BoxFit imageFit; final String? title; final List details; @override Size get preferredSize => Size.fromHeight(imageHeight); @override Widget build(BuildContext context) { return Row( crossAxisAlignment: .end, spacing: Theming.offset, children: [ Hero( tag: imageHeroTag, child: ClipRRect( borderRadius: Theming.borderRadiusSmall, child: SizedBox( height: imageHeight, width: imageWidth, child: imageUrl != null ? GestureDetector( onTap: () => showDialog( context: context, builder: (context) => ImageDialog(imageLargeUrl ?? imageUrl!), ), child: CachedImage(imageUrl!, fit: imageFit), ) : null, ), ), ), Expanded( child: Center( child: SingleChildScrollView( child: Column( crossAxisAlignment: .stretch, spacing: 5, children: [ if (title != null) GestureDetector( behavior: .opaque, onTap: () => SnackBarExtension.copy(context, title!), child: Text(title!, overflow: .fade, style: TextTheme.of(context).bodyLarge), ), ...details, ], ), ), ), ), ], ); } } class _Delegate extends SliverPersistentHeaderDelegate { const _Delegate({ required this.content, required this.title, required this.siteUrl, required this.bannerUrl, required this.tabBarConfig, required this.trailingTopButtons, required this.topPadding, }); final PreferredSizeWidget content; final double topPadding; final String? title; final List trailingTopButtons; final String? siteUrl; final String? bannerUrl; final TabBarConfig? tabBarConfig; @override double get minExtent => topPadding + Theming.normalTapTarget + (tabBarConfig != null ? Theming.minTapTarget : 0); @override double get maxExtent => minExtent + content.preferredSize.height + Theming.offset; @override bool shouldRebuild(covariant _Delegate oldDelegate) => topPadding != oldDelegate.topPadding || title != oldDelegate.title; @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { final theme = Theme.of(context); final minHeight = minExtent; final maxHeight = maxExtent; final transition = (shrinkOffset / (maxHeight - minHeight)).clamp(0.0, 1.0); final topButtons = Row( children: [ if (GoRouter.of(context).canPop()) IconButton( tooltip: 'Close', icon: const Icon(Icons.arrow_back_ios_rounded), onPressed: context.back, ) else const SizedBox(width: Theming.offset), if (title == null) const Spacer() else Expanded( child: Opacity( opacity: transition, child: Text(title!, style: theme.textTheme.bodyMedium, overflow: .ellipsis), ), ), ...trailingTopButtons, IconButton( tooltip: 'More', icon: const Icon(Ionicons.ellipsis_horizontal), onPressed: siteUrl != null ? () => showSheet(context, SimpleSheet.link(context, siteUrl!)) : null, ), ], ); final bannerBottomPadding = content.preferredSize.height / 2.0 + Theming.offset / 2; Widget body = Stack( fit: StackFit.expand, children: [ if (transition < 1) ...[ if (bannerUrl != null) ...[ Positioned.fill(bottom: bannerBottomPadding, child: CachedImage(bannerUrl!)), Positioned.fill( bottom: bannerBottomPadding, child: GestureDetector( onTap: () => showDialog(context: context, builder: (context) => ImageDialog(bannerUrl!)), child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.center, tileMode: TileMode.mirror, colors: [theme.colorScheme.surface, theme.colorScheme.surface.withAlpha(150)], ), ), ), ), ), ], Positioned( left: Theming.offset, right: Theming.offset, bottom: Theming.offset / 2, top: Theming.offset / 2 + topPadding + Theming.normalTapTarget, child: content, ), if (transition > 0.1) Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( color: theme.colorScheme.surface.withAlpha((transition * 255).floor()), ), ), ), ], Positioned( left: 0, right: 0, top: topPadding, height: Theming.normalTapTarget, child: topButtons, ), ], ); if (tabBarConfig != null) { body = Column( children: [ Flexible(child: body), Material( color: Colors.transparent, child: TabBar( tabAlignment: TabAlignment.center, splashBorderRadius: Theming.borderRadiusSmall, controller: tabBarConfig!.tabCtrl, isScrollable: true, tabs: tabBarConfig!.tabs, onTap: (index) { if (index == tabBarConfig!.tabCtrl.index) { tabBarConfig!.scrollToTop(); } }, ), ), ], ); } return transition < 1 ? body : ClipRect( child: BackdropFilter( filter: Theming.blurFilter, child: DecoratedBox( decoration: BoxDecoration(color: theme.navigationBarTheme.backgroundColor), child: body, ), ), ); } } ================================================ FILE: lib/widget/layout/dual_pane_with_tab_bar.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/util/theming.dart'; /// Two panes side by side, the left with capped width. /// There's a tab bar over the right one. class DualPaneWithTabBar extends StatelessWidget { const DualPaneWithTabBar({ required this.tabs, required this.tabCtrl, required this.scrollToTop, required this.leftPane, required this.rightPane, }); final List tabs; final TabController tabCtrl; final void Function() scrollToTop; final Widget leftPane; final Widget rightPane; @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); final topPadding = mediaQuery.padding.top + Theming.normalTapTarget; return Row( children: [ Flexible( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: Theming.windowWidthMedium), child: leftPane, ), ), Flexible( child: Stack( children: [ MediaQuery( data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: topPadding)), child: rightPane, ), Align( alignment: Alignment.topCenter, child: ClipRect( child: BackdropFilter( filter: Theming.blurFilter, child: DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).navigationBarTheme.backgroundColor, ), child: SizedBox( height: topPadding, child: Align( alignment: Alignment.bottomCenter, child: Material( color: Colors.transparent, child: TabBar( isScrollable: true, tabAlignment: TabAlignment.center, splashBorderRadius: Theming.borderRadiusSmall, tabs: tabs, controller: tabCtrl, onTap: (index) { if (index == tabCtrl.index) { scrollToTop(); } }, ), ), ), ), ), ), ), ), ], ), ), ], ); } } ================================================ FILE: lib/widget/layout/hiding_floating_action_button.dart ================================================ import 'package:flutter/material.dart'; /// Hides/Shows [child] on scroll. class HidingFloatingActionButton extends StatefulWidget { const HidingFloatingActionButton({ required super.key, required this.child, required this.scrollCtrl, }); final Widget child; final ScrollController scrollCtrl; @override State createState() => _HidingFloatingActionButtonState(); } class _HidingFloatingActionButtonState extends State with SingleTickerProviderStateMixin { late final AnimationController _animationCtrl; late final Animation _slideAnimation; late final Animation _fadeAnimation; var _visible = true; var _lastOffset = 0.0; void _visibility() { final pos = widget.scrollCtrl.positions.last; final dif = pos.pixels - _lastOffset; // If the position has moved enough from the last // spot or is out of bounds, hide/show the actions. if (dif > 15 || pos.pixels > pos.maxScrollExtent) { _lastOffset = pos.pixels; _animationCtrl.reverse().then((_) => setState(() => _visible = false)); } else if (dif < -15 || pos.pixels < pos.minScrollExtent) { _lastOffset = pos.pixels; setState(() => _visible = true); _animationCtrl.forward(); } } @override void initState() { super.initState(); widget.scrollCtrl.addListener(_visibility); _animationCtrl = AnimationController( duration: const Duration(milliseconds: 100), vsync: this, value: 1, ); _slideAnimation = Tween(begin: const Offset(0, 0.2), end: Offset.zero).animate(_animationCtrl); _fadeAnimation = Tween(begin: 0.3, end: 1.0).animate(_animationCtrl); } @override void dispose() { widget.scrollCtrl.removeListener(_visibility); _animationCtrl.dispose(); super.dispose(); } @override void didUpdateWidget(covariant HidingFloatingActionButton oldWidget) { super.didUpdateWidget(oldWidget); if (widget.scrollCtrl != oldWidget.scrollCtrl) { oldWidget.scrollCtrl.removeListener(_visibility); widget.scrollCtrl.addListener(_visibility); } } @override Widget build(BuildContext context) { if (!_visible) return const SizedBox(); return SlideTransition( position: _slideAnimation, child: FadeTransition(opacity: _fadeAnimation, child: widget.child), ); } } ================================================ FILE: lib/widget/layout/navigation_tool.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/util/theming.dart'; class BottomNavigation extends StatefulWidget { const BottomNavigation({ required this.selected, required this.items, required this.onChanged, required this.onSame, }); final int selected; final Map items; final void Function(int) onChanged; final void Function(int) onSame; @override State createState() => _BottomNavigationState(); } class _BottomNavigationState extends State { late int _selected = widget.selected; @override void didUpdateWidget(covariant BottomNavigation oldWidget) { super.didUpdateWidget(oldWidget); _selected = widget.selected; } @override Widget build(BuildContext context) { return ClipRect( child: BackdropFilter( filter: Theming.blurFilter, child: NavigationBar( height: BottomBar.height, selectedIndex: _selected, onDestinationSelected: (i) { if (_selected == i) { widget.onSame(i); } else { _selected = i; widget.onChanged(_selected); } }, destinations: [ for (final e in widget.items.entries) NavigationDestination(label: e.key, icon: Icon(e.value)), ], ), ), ); } } class SideNavigation extends StatefulWidget { const SideNavigation({ required this.selected, required this.items, required this.onChanged, required this.onSame, }); final int selected; final Map items; final void Function(int) onChanged; final void Function(int) onSame; @override State createState() => _SideNavigationState(); } class _SideNavigationState extends State { late int _selected = widget.selected; @override void didUpdateWidget(covariant SideNavigation oldWidget) { super.didUpdateWidget(oldWidget); _selected = widget.selected; } @override Widget build(BuildContext context) { final rail = NavigationRail( scrollable: true, selectedIndex: _selected, onDestinationSelected: (i) { if (_selected == i) { widget.onSame(i); } else { _selected = i; widget.onChanged(_selected); } }, destinations: [ for (final e in widget.items.entries) NavigationRailDestination(label: Text(e.key), icon: Icon(e.value)), ], ); return ClipRect( child: BackdropFilter(filter: Theming.blurFilter, child: rail), ); } } class BottomBar extends StatelessWidget { const BottomBar(this.items); final List items; static const height = 60.0; @override Widget build(BuildContext context) { final bottomPadding = MediaQuery.paddingOf(context).bottom; return ClipRect( child: BackdropFilter( filter: Theming.blurFilter, child: SizedBox( height: height + bottomPadding, child: Material( elevation: 3, color: Theme.of(context).navigationBarTheme.backgroundColor, surfaceTintColor: ColorScheme.of(context).surfaceTint, shadowColor: Colors.transparent, child: Padding( padding: .only(bottom: bottomPadding), child: Row(mainAxisAlignment: .spaceEvenly, children: items), ), ), ), ), ); } } class BottomBarButton extends StatelessWidget { const BottomBarButton({ required this.text, required this.icon, required this.onTap, this.foregroundColor, }); final String text; final IconData icon; final void Function() onTap; final Color? foregroundColor; @override Widget build(BuildContext context) { return Padding( padding: const .symmetric(horizontal: Theming.offset), child: TextButton.icon( label: Text(text), icon: Icon(icon), onPressed: onTap, style: TextButton.styleFrom( foregroundColor: foregroundColor, iconColor: foregroundColor, iconSize: Theming.iconBig, ), ), ); } } ================================================ FILE: lib/widget/layout/top_bar.dart ================================================ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:otraku/extension/build_context_extension.dart'; import 'package:otraku/util/theming.dart'; const _preferredSize = Size.fromHeight(Theming.normalTapTarget); /// A top app bar implementation that uses a blurred, translucent background. class TopBar extends StatelessWidget implements PreferredSizeWidget { const TopBar({super.key, this.title, this.trailing = const []}); final String? title; final List trailing; @override Size get preferredSize => _preferredSize; @override Widget build(BuildContext context) { final topPadding = MediaQuery.paddingOf(context).top; return ClipRect( child: BackdropFilter( filter: Theming.blurFilter, child: Container( height: topPadding + preferredSize.height, decoration: BoxDecoration(color: Theme.of(context).navigationBarTheme.backgroundColor), padding: .only(top: topPadding), alignment: Alignment.center, child: Row( children: [ if (GoRouter.of(context).canPop()) IconButton( tooltip: 'Close', icon: const Icon(Icons.arrow_back_ios_rounded), onPressed: context.back, ) else const SizedBox(width: Theming.offset), if (title != null) Expanded( child: Text( title!, style: TextTheme.of(context).titleMedium, overflow: .ellipsis, maxLines: 1, ), ), ...trailing, ], ), ), ), ); } } /// Dummy widget for when the app bar changes depending on the current tab /// and a tab doesn't have an associated app bar. class EmptyTopBar extends StatelessWidget implements PreferredSizeWidget { const EmptyTopBar(); @override Size get preferredSize => _preferredSize; @override Widget build(BuildContext context) { return SizedBox(height: MediaQuery.paddingOf(context).top + _preferredSize.height); } } /// An [AnimatedSwitcher] wrapper around any [PreferredSizeWidget]. /// Used for app bars that change depending on the current page tab. class TopBarAnimatedSwitcher extends StatelessWidget implements PreferredSizeWidget { const TopBarAnimatedSwitcher(this.child); final PreferredSizeWidget? child; @override Size get preferredSize => child?.preferredSize ?? const Size.fromHeight(0); @override Widget build(BuildContext context) { return AnimatedSwitcher(duration: const Duration(milliseconds: 200), child: child); } } ================================================ FILE: lib/widget/loaders.dart ================================================ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/widget/shimmer.dart'; class Loader extends StatelessWidget { const Loader(); @override Widget build(BuildContext context) => Shimmer( ShimmerItem( Container( width: 60, height: 15, decoration: BoxDecoration( borderRadius: Theming.borderRadiusSmall, color: ColorScheme.of(context).surfaceContainerHighest, ), ), ), ); } class SliverRefreshControl extends StatelessWidget { const SliverRefreshControl({required this.onRefresh}); final void Function() onRefresh; @override Widget build(BuildContext context) { return SliverPadding( padding: .only(top: MediaQuery.paddingOf(context).top + Theming.offset), sliver: CupertinoSliverRefreshControl( refreshIndicatorExtent: 15, refreshTriggerPullDistance: 160, onRefresh: () { onRefresh(); return Future.value(); }, builder: (_, refreshState, pulledExtent, refreshTriggerPullDistance, refreshIndicatorExtent) { double visibility = 0; if (pulledExtent > refreshIndicatorExtent) { pulledExtent -= refreshIndicatorExtent; refreshTriggerPullDistance -= refreshIndicatorExtent; visibility = pulledExtent / refreshTriggerPullDistance; if (visibility > 1) visibility = 1; } return switch (refreshState) { RefreshIndicatorMode.inactive => const SizedBox(), _ => Opacity( opacity: visibility, child: const Center(child: Loader()), ), }; }, ), ); } } class SliverFooter extends StatelessWidget { const SliverFooter({this.loading = false}); final bool loading; @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Center( child: Padding( padding: .only( top: Theming.offset, bottom: MediaQuery.paddingOf(context).bottom + Theming.offset, ), child: loading ? const Loader() : null, ), ), ); } } ================================================ FILE: lib/widget/paged_view.dart ================================================ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:otraku/util/paged.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/layout/constrained_view.dart'; import 'package:otraku/widget/loaders.dart'; class PagedView extends StatelessWidget { const PagedView({ required this.provider, required this.scrollCtrl, required this.onRefresh, required this.onData, this.padded = true, this.header, }); final ProviderListenable>> provider; /// If [scrollCtrl] is [PagedController], pagination will automatically work. final ScrollController scrollCtrl; /// The [invalidate] parameter is the method of [PagedView]'s [ref]. /// The parameter is useful, because the parent widget /// may not have a [WidgetRef] at its disposal. final void Function(void Function(ProviderOrFamily) invalidate) onRefresh; /// [onData] should return a sliver widget, displaying the items. final Widget Function(Paged) onData; /// If [padded] is true, the result of [onData] will be padded. final bool padded; final Widget? header; @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, _) { ref.listen( provider, (_, s) => s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())), ); return ref .watch(provider) .unwrapPrevious() .when( loading: () => const Center(child: Loader()), error: (_, _) => CustomScrollView( physics: Theming.bouncyPhysics, slivers: [ SliverRefreshControl(onRefresh: () => onRefresh(ref.invalidate)), ?header, const SliverFillRemaining(child: Center(child: Text('Failed to load'))), ], ), data: (data) { return ConstrainedView( padded: padded, child: CustomScrollView( physics: Theming.bouncyPhysics, controller: scrollCtrl, slivers: [ SliverRefreshControl(onRefresh: () => onRefresh(ref.invalidate)), ?header, data.items.isEmpty ? const SliverFillRemaining(child: Center(child: Text('No results'))) : onData(data), SliverFooter(loading: data.hasNext), ], ), ); }, ); }, ); } } ================================================ FILE: lib/widget/shadowed_overflow_list.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/util/theming.dart'; /// A horizontal list with inner shadow /// on the left and right that indicates overflow. class ShadowedOverflowList extends StatelessWidget { const ShadowedOverflowList({ required this.itemCount, required this.itemBuilder, this.shrinkWrap = false, this.itemExtent, }); final int itemCount; final Widget Function(BuildContext context, int i) itemBuilder; final double? itemExtent; final bool shrinkWrap; @override Widget build(BuildContext context) { return Stack( children: [ ListView.builder( scrollDirection: Axis.horizontal, padding: const .only(left: Theming.offset, right: Theming.offset / 2), itemExtent: itemExtent, itemCount: itemCount, shrinkWrap: shrinkWrap, itemBuilder: (context, i) => Padding( padding: const .only(right: Theming.offset / 2), child: itemBuilder(context, i), ), ), Positioned( top: 0, left: 0, bottom: 0, child: SizedBox( width: Theming.offset, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ ColorScheme.of(context).surface, ColorScheme.of(context).surface.withValues(alpha: 0), ], ), ), ), ), ), Positioned( top: 0, right: 0, bottom: 0, child: SizedBox( width: Theming.offset, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.centerRight, end: Alignment.centerLeft, colors: [ ColorScheme.of(context).surface, ColorScheme.of(context).surface.withValues(alpha: 0), ], ), ), ), ), ), ], ); } } ================================================ FILE: lib/widget/sheets.dart ================================================ import 'package:flutter/material.dart'; import 'package:ionicons/ionicons.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/widget/layout/adaptive_scaffold.dart'; /// Used to open [DraggableScrollableSheet]. Future showSheet(BuildContext context, Widget sheet) => showModalBottomSheet( context: context, builder: (context) => sheet, useSafeArea: true, isScrollControlled: true, backgroundColor: Colors.transparent, ); /// An implementation of [DraggableScrollableSheet] with opaque background. class SimpleSheet extends StatelessWidget { const SimpleSheet({required this.builder, this.initialHeight}); factory SimpleSheet.list(List children) => SimpleSheet( initialHeight: Theming.normalTapTarget * children.length + Theming.offset, builder: (context, scrollCtrl) => ListView( controller: scrollCtrl, padding: const .only(top: Theming.offset), children: children, ), ); factory SimpleSheet.link(BuildContext context, String link, [List children = const []]) => SimpleSheet.list([ ...children, ListTile( title: const Text('Copy Link'), leading: const Icon(Ionicons.clipboard_outline), onTap: () { SnackBarExtension.copy(context, link); Navigator.pop(context); }, ), ListTile( title: const Text('Open in Browser'), leading: const Icon(Ionicons.link_outline), onTap: () { SnackBarExtension.launch(context, link); Navigator.pop(context); }, ), ]); final Widget Function(BuildContext, ScrollController) builder; final double? initialHeight; @override Widget build(BuildContext context) { Widget? sheet; final screenHeight = MediaQuery.sizeOf(context).height; final bottomPadding = MediaQuery.paddingOf(context).bottom; final initialFraction = initialHeight != null ? (initialHeight! + bottomPadding + Theming.offset).clamp(0, screenHeight) / screenHeight : 0.5; return DraggableScrollableSheet( expand: false, initialChildSize: initialFraction, minChildSize: initialFraction < 0.25 ? initialFraction : 0.25, builder: (context, scrollCtrl) { sheet ??= Container( constraints: const BoxConstraints(maxWidth: Theming.windowWidthMedium), decoration: BoxDecoration( color: ColorScheme.of(context).surface, borderRadius: const BorderRadius.vertical(top: Theming.radiusBig), ), child: Material(color: Colors.transparent, child: builder(context, scrollCtrl)), ); return sheet!; }, ); } } /// A wide implementation of [DraggableScrollableSheet] /// with a lane of buttons at the bottom. class SheetWithButtonRow extends StatelessWidget { const SheetWithButtonRow({required this.builder, this.buttons}); final Widget Function(BuildContext, ScrollController) builder; final Widget? buttons; @override Widget build(BuildContext context) { Widget? sheet; return Padding( padding: MediaQuery.viewInsetsOf(context), child: DraggableScrollableSheet( expand: false, builder: (context, scrollCtrl) { sheet ??= _sheetBody(context, scrollCtrl); return sheet!; }, ), ); } Widget _sheetBody(BuildContext context, ScrollController scrollCtrl) => Center( child: Container( constraints: const BoxConstraints(maxWidth: Theming.windowWidthMedium), decoration: BoxDecoration( color: ColorScheme.of(context).surface, borderRadius: const BorderRadius.vertical(top: Theming.radiusBig), ), child: ScaffoldMessenger( child: AdaptiveScaffold( sheetMode: true, bottomBar: buttons, child: builder(context, scrollCtrl), ), ), ), ); } ================================================ FILE: lib/widget/shimmer.dart ================================================ import 'package:flutter/material.dart'; class Shimmer extends StatefulWidget { static ShimmerState? of(BuildContext context) => context.findAncestorStateOfType(); const Shimmer(this.child); final Widget child; @override ShimmerState createState() => ShimmerState(); } class ShimmerState extends State with SingleTickerProviderStateMixin { late AnimationController _ctrl; late LinearGradient _gradient; @override void initState() { super.initState(); _ctrl = AnimationController.unbounded(vsync: this, value: 0.5) ..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000)); } @override void didChangeDependencies() { super.didChangeDependencies(); final back = ColorScheme.of(context).surfaceContainerHighest; final hsl = HSLColor.fromColor(back); final l = hsl.lightness; final front = hsl.withLightness(l < 0.5 ? l + 0.1 : l - 0.1).toColor(); _gradient = LinearGradient( begin: const Alignment(-1.0, -0.3), end: const Alignment(1.0, 0.3), stops: const [0.1, 0.3, 0.4], colors: [back, front, back], ); } @override void dispose() { _ctrl.dispose(); super.dispose(); } Listenable get animation => _ctrl; LinearGradient get gradient => LinearGradient( transform: _SlidingGradientTransform(_ctrl.value), colors: _gradient.colors, stops: _gradient.stops, begin: _gradient.begin, end: _gradient.end, ); Size? get size { final box = context.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return null; return box.size; } Offset getOffset(RenderBox descendant) => descendant.localToGlobal(Offset.zero, ancestor: context.findRenderObject() as RenderBox); @override Widget build(BuildContext context) => widget.child; } class ShimmerItem extends StatefulWidget { const ShimmerItem(this.child); final Widget child; @override ShimmerItemState createState() => ShimmerItemState(); } class ShimmerItemState extends State { Listenable? _anim; void _update() => setState(() {}); @override void didChangeDependencies() { super.didChangeDependencies(); _anim?.removeListener(_update); _anim = Shimmer.of(context)?.animation?..addListener(_update); } @override void dispose() { _anim?.removeListener(_update); super.dispose(); } @override Widget build(BuildContext context) { final shimmer = Shimmer.of(context); if (shimmer == null) return const SizedBox(); final size = shimmer.size; if (size == null) return const SizedBox(); final offset = shimmer.getOffset(context.findRenderObject() as RenderBox); return ShaderMask( blendMode: BlendMode.srcATop, shaderCallback: (bounds) => shimmer.gradient.createShader( Rect.fromLTWH(-offset.dx, -offset.dy, size.width, size.height), ), child: widget.child, ); } } class _SlidingGradientTransform extends GradientTransform { const _SlidingGradientTransform(this.percent); final double percent; @override Matrix4 transform(Rect bounds, {TextDirection? textDirection}) => Matrix4.translationValues(bounds.width * percent, 0.0, 0.0); } ================================================ FILE: lib/widget/swipe_switcher.dart ================================================ import 'package:flutter/material.dart'; /// Rotates between [children] when swiped. class SwipeSwitcher extends StatelessWidget { const SwipeSwitcher({required this.index, required this.children, required this.onChanged}); final int index; final List children; final void Function(int) onChanged; static const _triggerOffset = 20.0; @override Widget build(BuildContext context) { var swipeStart = 0.0; return GestureDetector( behavior: .translucent, onHorizontalDragStart: (start) => swipeStart = start.globalPosition.dx, onHorizontalDragUpdate: (update) { if (swipeStart == 0) return; final dif = swipeStart - update.globalPosition.dx; if (dif > _triggerOffset) { onChanged(index < children.length - 1 ? index + 1 : 0); swipeStart = 0; } else if (dif < -_triggerOffset) { onChanged(index > 0 ? index - 1 : children.length - 1); swipeStart = 0; } }, child: children[index], ); } } ================================================ FILE: lib/widget/table_list.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/card_extension.dart'; import 'package:otraku/util/theming.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; class TableList extends StatelessWidget { const TableList(this.items, {required this.highContrast}); final List<(String, String)> items; final bool highContrast; @override Widget build(BuildContext context) { if (items.isEmpty) return const SizedBox(); return CardExtension.highContrast(highContrast)( child: Padding( padding: const .symmetric(vertical: Theming.offset), child: ListView.separated( shrinkWrap: true, itemCount: items.length, padding: .zero, physics: const NeverScrollableScrollPhysics(), separatorBuilder: (context, _) => const Divider(), itemBuilder: (context, i) => Row( children: [ const SizedBox(width: Theming.offset), Text(items[i].$1), const SizedBox(width: Theming.offset), Expanded( child: GestureDetector( behavior: .opaque, onTap: () => SnackBarExtension.copy(context, items[i].$2), child: Text(items[i].$2, textAlign: .end), ), ), const SizedBox(width: Theming.offset), ], ), ), ), ); } } class SliverTableList extends StatelessWidget { const SliverTableList(this.items, {required this.highContrast}); final List<(String, String)> items; final bool highContrast; @override Widget build(BuildContext context) { if (items.isEmpty) return const SliverToBoxAdapter(); final colorScheme = ColorScheme.of(context); return DecoratedSliver( decoration: highContrast ? BoxDecoration( borderRadius: Theming.borderRadiusSmall, border: .all(color: colorScheme.outlineVariant), ) : BoxDecoration( borderRadius: Theming.borderRadiusSmall, color: colorScheme.surfaceContainerLow, boxShadow: kElevationToShadow[1], ), sliver: SliverPadding( padding: const .symmetric(vertical: Theming.offset), sliver: SliverList.separated( itemCount: items.length, separatorBuilder: (context, _) => const Divider(), itemBuilder: (context, i) => Row( children: [ const SizedBox(width: Theming.offset), Text(items[i].$1), const SizedBox(width: Theming.offset), Expanded( child: GestureDetector( behavior: .opaque, onTap: () => SnackBarExtension.copy(context, items[i].$2), child: Text(items[i].$2, textAlign: .end), ), ), const SizedBox(width: Theming.offset), ], ), ), ), ); } } ================================================ FILE: lib/widget/text_rail.dart ================================================ import 'package:flutter/material.dart'; /// Lists text details in a fancy way, marking /// the ones that come with a [true] value. class TextRail extends StatelessWidget { const TextRail(this.items, {this.style, this.maxLines}); final Map items; final TextStyle? style; final int? maxLines; @override Widget build(BuildContext context) { if (items.isEmpty) return const SizedBox(); const spacing = TextSpan(text: ' • '); final style = this.style ?? TextTheme.of(context).labelSmall; final highlightStyle = style?.copyWith(color: ColorScheme.of(context).primary); return Text.rich( overflow: .fade, maxLines: maxLines, TextSpan( style: style, children: [ for (int i = 0; i < items.length - 1; i++) ...[ TextSpan( text: items.keys.elementAt(i), style: items.values.elementAt(i) ? highlightStyle : null, ), spacing, ], TextSpan(text: items.keys.last, style: items.values.last ? highlightStyle : null), ], ), ); } } ================================================ FILE: lib/widget/timestamp.dart ================================================ import 'package:flutter/material.dart'; import 'package:otraku/extension/date_time_extension.dart'; import 'package:otraku/extension/snack_bar_extension.dart'; import 'package:otraku/util/theming.dart'; class Timestamp extends StatelessWidget { const Timestamp( this.dateTime, this.analogClock, { this.leading = const Icon(Icons.history_rounded, size: Theming.iconSmall), }); final DateTime dateTime; final bool analogClock; final Widget leading; @override Widget build(BuildContext context) { final onTap = () => SnackBarExtension.show( context, dateTime.formattedDateTimeFromSeconds(analogClock), canCopyText: true, ); return Semantics( onTap: onTap, onTapHint: 'show absolute creation time', tooltip: 'Creation Time', child: GestureDetector( onTap: onTap, child: Row( mainAxisSize: .min, spacing: 5, children: [ leading, Text( _relativeTime(), style: TextTheme.of(context).labelSmall, overflow: .ellipsis, maxLines: 1, ), ], ), ), ); } String _relativeTime() { final diff = DateTime.now().difference(dateTime); final seconds = diff.inSeconds; if (seconds < 61) { if (seconds > 4) return '$seconds seconds ago'; return 'just now'; } final minutes = diff.inMinutes; if (minutes < 61) { if (minutes > 1) return '$minutes minutes ago'; return 'last minute'; } final hours = diff.inHours; if (hours < 25) { if (hours > 1) return '$hours hours ago'; return 'last hour'; } final days = diff.inDays; if (days < 31) { if (days > 1) return '$days days ago'; return 'yesterday'; } final months = days ~/ 30; if (months < 13) { if (months > 1) return '$months months ago'; return 'last month'; } final years = months ~/ 12; if (years > 1) return '$years years ago'; return 'last year'; } } ================================================ FILE: pubspec.yaml ================================================ name: otraku description: An unofficial AniList app. publish_to: 'none' version: 1.12.1+94 environment: sdk: '>=3.10.0 <4.0.0' dependencies: flutter: sdk: flutter # State management. flutter_riverpod: ^3.3.1 # Routing. go_router: ^17.1.0 # Data fetching. http: ^1.6.0 # Lightweight storage. hive: ^2.2.3 # Access to device storage. Used for [hive] setup. path_provider: ^2.1.5 # Secure storage for the access tokens. flutter_secure_storage: ^10.0.0 # Markdown to HTML parsing. markdown: ^7.3.1 # Used for configuring [cached_network_image]. flutter_cache_manager: ^3.4.1 # Image caching. cached_network_image: ^3.4.1 # Opening links in the browser. url_launcher: ^6.3.2 # Access to platform theme and easy theme interpolation. dynamic_color: ^1.8.1 # Background tasks for notification fetching. workmanager: ^0.9.0+3 # Sending device notifications. flutter_local_notifications: ^21.0.0 # Parsing html into flutter widgets. flutter_widget_from_html_core: ^0.17.0 # Transformation calculations. vector_math: ^2.2.0 # An addition to the material icons. ionicons: ^0.2.2 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 flutter_icons: ios: true android: true image_path: "assets/icons/ios.png" adaptive_icon_background: "#0D161E" adaptive_icon_foreground: "assets/icons/android.png" flutter: uses-material-design: true assets: - assets/icons/about.png fonts: - family: Rubik fonts: - asset: assets/fonts/Rubik-VariableFont_wght.ttf - asset: assets/fonts/Rubik-Italic-VariableFont_wght.ttf style: italic