├── .gitattributes ├── .gitignore ├── .metadata ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle.kts │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── dev │ │ └── res │ │ │ ├── drawable-hdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-mdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xxhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xxxhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-anydpi-v26 │ │ │ └── ic_launcher.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ └── values │ │ │ ├── colors.xml │ │ │ └── strings.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── otraku │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-hdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-mdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable-xhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xxhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xxxhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable │ │ │ ├── launch_background.xml │ │ │ └── notification_icon.png │ │ │ ├── mipmap-anydpi-v26 │ │ │ └── ic_launcher.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher_background.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ ├── values │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ │ └── xml │ │ │ └── backup_rules.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle.kts ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle.kts ├── assets ├── fonts │ ├── Rubik-Italic-VariableFont_wght.ttf │ └── Rubik-VariableFont_wght.ttf └── icons │ ├── about.png │ ├── about_alt.png │ ├── android.png │ └── ios.png ├── fastlane └── metadata │ └── android │ └── 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 │ ├── full_description.txt │ ├── images │ ├── icon.png │ ├── phoneScreenshots │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ └── 9.jpg │ └── sevenInchScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ └── 3.png │ ├── short_description.txt │ └── title.txt ├── flutter_launcher_icons-dev.yaml ├── ios ├── .gitignore ├── Flutter │ ├── .last_build_id │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Podfile.lock ├── 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 └── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon-dev.appiconset │ │ ├── AppIcon-dev-1024x1024@1x.png │ │ ├── AppIcon-dev-20x20@1x.png │ │ ├── AppIcon-dev-20x20@2x.png │ │ ├── AppIcon-dev-20x20@3x.png │ │ ├── AppIcon-dev-29x29@1x.png │ │ ├── AppIcon-dev-29x29@2x.png │ │ ├── AppIcon-dev-29x29@3x.png │ │ ├── AppIcon-dev-40x40@1x.png │ │ ├── AppIcon-dev-40x40@2x.png │ │ ├── AppIcon-dev-40x40@3x.png │ │ ├── AppIcon-dev-60x60@2x.png │ │ ├── AppIcon-dev-60x60@3x.png │ │ ├── AppIcon-dev-76x76@1x.png │ │ ├── AppIcon-dev-76x76@2x.png │ │ ├── AppIcon-dev-83.5x83.5@2x.png │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-1024x1024@1x.png │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ └── Icon-App-83.5x83.5@2x.png │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ ├── README.md │ │ ├── splash_icon-1.png │ │ ├── splash_icon-2.png │ │ └── splash_icon.png │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ └── Runner-Bridging-Header.h ├── lib ├── extension │ ├── build_context_extension.dart │ ├── color_extension.dart │ ├── date_time_extension.dart │ ├── enum_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_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_filter_view.dart │ │ ├── discover_floating_action.dart │ │ ├── discover_media_grid.dart │ │ ├── discover_media_simple_grid.dart │ │ ├── discover_model.dart │ │ ├── discover_provider.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_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_grids.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 │ ├── scroll_physics.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.lock └── pubspec.yaml /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .build/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | .swiftpm/ 13 | migrate_working_dir/ 14 | 15 | # IntelliJ related 16 | *.iml 17 | *.ipr 18 | *.iws 19 | .idea/ 20 | 21 | # The .vscode folder contains launch configuration and tasks you configure in 22 | # VS Code which you may wish to be included in version control, so this line 23 | # is commented out by default. 24 | #.vscode/ 25 | 26 | # Flutter/Dart/Pub related 27 | **/doc/api/ 28 | **/ios/Flutter/.last_build_id 29 | .dart_tool/ 30 | .flutter-plugins 31 | .flutter-plugins-dependencies 32 | .pub-cache/ 33 | .pub/ 34 | /build/ 35 | 36 | # Web related 37 | lib/generated_plugin_registrant.dart 38 | 39 | # Symbolication related 40 | app.*.symbols 41 | 42 | # Obfuscation related 43 | app.*.map.json 44 | 45 | # Android Studio will place build artifacts here 46 | /android/app/debug 47 | /android/app/profile 48 | /android/app/release 49 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: f7a6a7906be96d2288f5d63a5a54c515a6e987fe 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Dev Android", 9 | "request": "launch", 10 | "type": "dart", 11 | "args": [ 12 | "--flavor", 13 | "dev" 14 | ], 15 | }, 16 | { 17 | "name": "Dev IOS", 18 | "request": "launch", 19 | "type": "dart", 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Otraku 2 | An unofficial AniList app. 3 | 4 |

5 | 6 |

7 | 8 |

9 | Google PlayIzzyOnDroid (F-Droid)Privacy Policy 10 |

11 |

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

14 | 15 |

16 |

Screenshots 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |

30 |
Building for android 31 | 32 | 1. Run `flutter build apk --split-per-abi` 33 | 2. Grab the apk release build file with your required ABI 34 |
35 |
Building for iOS 36 | 37 | 1. Run `flutter build ios --no-codesign` 38 | 2. Copy `./build/ios/iphoneos/Runner.app` into a `Payload` directory 39 | 3. Compress `Payload` and change extension to `.ipa` 40 |
41 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | 3 | linter: 4 | rules: 5 | # Often unnecessary. 6 | use_key_in_widget_constructors: false 7 | 8 | # For closures. 9 | prefer_function_declarations_over_variables: false 10 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /keystore.jks 7 | /keystore.properties 8 | /local.properties 9 | GeneratedPluginRegistrant.java 10 | .cxx/ -------------------------------------------------------------------------------- /android/app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.util.Properties 2 | import java.io.FileInputStream 3 | 4 | plugins { 5 | id("com.android.application") 6 | id("kotlin-android") 7 | id("dev.flutter.flutter-gradle-plugin") 8 | } 9 | 10 | val keystoreProperties = Properties() 11 | val keystorePropertiesFile = rootProject.file("keystore.properties") 12 | if (keystorePropertiesFile.exists()) { 13 | keystoreProperties.load(FileInputStream(keystorePropertiesFile)) 14 | } 15 | 16 | android { 17 | namespace = "com.otraku.app" 18 | compileSdk = flutter.compileSdkVersion 19 | 20 | ndkVersion = "27.2.12479018" 21 | // ndkVersion = flutter.ndkVersion 22 | 23 | compileOptions { 24 | sourceCompatibility = JavaVersion.VERSION_11 25 | targetCompatibility = JavaVersion.VERSION_11 26 | 27 | // Desugaring is required by flutter_local_notifications. 28 | isCoreLibraryDesugaringEnabled = true 29 | } 30 | 31 | kotlinOptions { 32 | jvmTarget = JavaVersion.VERSION_11.toString() 33 | } 34 | 35 | defaultConfig { 36 | applicationId = "com.otraku.app" 37 | minSdk = 26 38 | targetSdk = flutter.targetSdkVersion 39 | versionCode = flutter.versionCode 40 | versionName = flutter.versionName 41 | } 42 | 43 | signingConfigs { 44 | create("release") { 45 | storeFile = file(rootDir.canonicalPath + "/" + keystoreProperties["releaseKeyStore"]) 46 | storePassword = keystoreProperties["releaseStorePassword"] as String 47 | keyPassword = keystoreProperties["releaseKeyPassword"] as String 48 | keyAlias = keystoreProperties["releaseKeyAlias"] as String 49 | } 50 | } 51 | 52 | buildTypes { 53 | release { 54 | signingConfig = signingConfigs.getByName("release") 55 | } 56 | } 57 | 58 | flavorDimensions += "default" 59 | productFlavors { 60 | create("dev") { 61 | dimension = "default" 62 | applicationIdSuffix = ".dev" 63 | } 64 | } 65 | } 66 | 67 | dependencies { 68 | // Desugaring is required by flutter_local_notifications. 69 | coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") 70 | } 71 | 72 | flutter { 73 | source = "../.." 74 | } 75 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/app/src/dev/res/drawable-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/drawable-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/dev/res/drawable-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/drawable-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/dev/res/drawable-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/drawable-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/dev/res/drawable-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/drawable-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/dev/res/drawable-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/drawable-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/dev/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #E3F2FF 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/dev/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Otraku 4 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/otraku/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.otraku.app 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/drawable/notification_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 19 | 20 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #0D161E 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Otraku 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 19 | 20 | -------------------------------------------------------------------------------- /android/app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 24 | 32 | 36 | 37 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() 9 | rootProject.layout.buildDirectory.value(newBuildDir) 10 | 11 | subprojects { 12 | val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) 13 | project.layout.buildDirectory.value(newSubprojectBuildDir) 14 | } 15 | subprojects { 16 | project.evaluationDependsOn(":app") 17 | } 18 | 19 | tasks.register("clean") { 20 | delete(rootProject.layout.buildDirectory) 21 | } 22 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError 2 | android.useAndroidX=true 3 | android.enableJetifier=true -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip 6 | -------------------------------------------------------------------------------- /android/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | val flutterSdkPath = run { 3 | val properties = java.util.Properties() 4 | file("local.properties").inputStream().use { properties.load(it) } 5 | val flutterSdkPath = properties.getProperty("flutter.sdk") 6 | require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } 7 | flutterSdkPath 8 | } 9 | 10 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 11 | 12 | repositories { 13 | google() 14 | mavenCentral() 15 | gradlePluginPortal() 16 | } 17 | } 18 | 19 | plugins { 20 | id("dev.flutter.flutter-plugin-loader") version "1.0.0" 21 | id("com.android.application") version "8.7.0" apply false 22 | id("org.jetbrains.kotlin.android") version "1.8.22" apply false 23 | } 24 | 25 | include(":app") 26 | 27 | -------------------------------------------------------------------------------- /assets/fonts/Rubik-Italic-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/assets/fonts/Rubik-Italic-VariableFont_wght.ttf -------------------------------------------------------------------------------- /assets/fonts/Rubik-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/assets/fonts/Rubik-VariableFont_wght.ttf -------------------------------------------------------------------------------- /assets/icons/about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/assets/icons/about.png -------------------------------------------------------------------------------- /assets/icons/about_alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/assets/icons/about_alt.png -------------------------------------------------------------------------------- /assets/icons/android.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/assets/icons/android.png -------------------------------------------------------------------------------- /assets/icons/ios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/assets/icons/ios.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/59.txt: -------------------------------------------------------------------------------- 1 | - Added calendar in discover to view and filter new episode schedules 2 | - Option for pure background in settings now not only makes dark backgrounds black, but also light backgrounds white 3 | - Fixed lazy-loading in "Following" on the media page 4 | - Other fixes and improvements -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/63.txt: -------------------------------------------------------------------------------- 1 | - Collection searching goes through both titles and notes 2 | - Activity replies have a "Reply" button for automatic mentions 3 | - Tapping on markdown images opens them as a popup 4 | - Tapping on user mentions is not handled as a link, but directly opens the user page 5 | - Tapping on a ranking in a media's statistics page redirects to the discover tab with added filters 6 | - Deep linking on android, if configured in settings 7 | - List status on related media in media pages 8 | - And other visual improvements and fixes -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/65.txt: -------------------------------------------------------------------------------- 1 | - Added collection filters for public/private entries and for entries with/without notes 2 | - Changed release year filter design 3 | - In fields, you can long-tap the decrement/increment buttons to set the value to min/max 4 | - Reduced minimum year in release year filter to 1917 5 | - AniList settings are saved with a floating action button now 6 | - Fixed collection refresh forgetting the selected list 7 | - Fixed missing entries in collections and ignored name preferences 8 | - Fixed settings not reflecting account switching -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/66.txt: -------------------------------------------------------------------------------- 1 | - Added collection filters for public/private entries and for entries with/without notes 2 | - Changed release year filter design 3 | - In fields, you can long-tap the decrement/increment buttons to set the value to min/max 4 | - Reduced minimum year in release year filter to 1917 5 | - AniList settings are saved with a floating action button now 6 | - Fixed collection refresh forgetting the selected list 7 | - Fixed missing entries in collections, ignored name preferences and settings not reflecting account switching -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/69.txt: -------------------------------------------------------------------------------- 1 | - AniList Markdown is supported almost fully 2 | - AniList links in markdown text are opened within the app 3 | - More markdown quick access buttons in the composition sheet 4 | - Collection previews can be filtered like full collections 5 | - User/Discover reviews can be filtered by media type 6 | - You can long-press to copy a media description 7 | - Redesigned media overview tab and other elements 8 | - Fixed bugs around deep link opening 9 | - Image popups are also cached 10 | - Other fixes and improvements -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/72.txt: -------------------------------------------------------------------------------- 1 | - If your filtered collections are empty, a button can redirect you to discover with copied filters 2 | - Tag categories in the tag sheet are sorted alphabetically 3 | - Separate synonym titles on media pages 4 | - Reordered fields in the entry sheet and chapter/volume fields switch based on left-handed mode 5 | - Added an indication on whether collection/discover filters are active 6 | - Refreshable media/user pages 7 | - Fixed emojis, some filter names, collection tiles 8 | - Visual tweaks and slightly darker dark mode -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/73.txt: -------------------------------------------------------------------------------- 1 | - Toggled activity/reply like buttons use the primary color 2 | - Cleaner error messages for failed connection/requests that now appear as toasts 3 | - Replaced "gradient" sheets for activity menus, discover type selection and the like with normal sheets (may still need polishing) 4 | - Fixed collection sorting 5 | - Fixed activity/reply like timeout message 6 | - Fixed home tab switching 7 | - Fixed user refresh retrying multiple times -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/77.txt: -------------------------------------------------------------------------------- 1 | - Tablet support with better layout on wide screens 2 | - New studio page design 3 | - New recommendations design in the media page 4 | - Activity/Reply like icons are different depending on whether the item is liked or not 5 | - Toast messages were replaced by snackbars 6 | - Overall design has been tweaked in many areas 7 | - Fixed progress-incrementing button spamming 8 | - Fixed language order when selecting voice actor language 9 | - And more tweaks and fixes -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/80.txt: -------------------------------------------------------------------------------- 1 | - In the filter sheets for collections and discover, you can set a custom default configuration 2 | - Basic AniList interactions are now supported without logging in 3 | - Easier account switching from the profile tab 4 | - You can reorder favorites and easily unfavorite them 5 | - Timestamps are now relative, but you can tap them for an absolute date 6 | - When incrementing the episode count from 0 on an entry in some lists, a pop up will offer to also change the list status -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/82.txt: -------------------------------------------------------------------------------- 1 | - Chips on the media page are now a grid, not a scrollable row 2 | - Fixed the the favorites editing button appearing in others' favorites 3 | - Fixed edge cases in entry saving/removing 4 | - Fixed list statuses in media recommendations mixing up anime and manga 5 | - Fixed notification timestamps taking too much space 6 | - Shortened the snackbar timeout -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/83.txt: -------------------------------------------------------------------------------- 1 | - 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. 2 | - Added a doujin filter in the discover filter sheet. 3 | - 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. -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/84.txt: -------------------------------------------------------------------------------- 1 | - Added forum page with thread filters 2 | - Added thread pages with navigation, commenting, liking and subscribing (thread writing/editing is not yet done) 3 | - Added a tab on media pages with related threads 4 | - Added tabs with user's threads and comments on users' social pages 5 | - Fixed bugs related to collections, advanced scores and home page search focusing -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Otraku aims to support most AniList features and it already covers: 2 | 3 | - Tracking media and managing/filtering collections 4 | - Browsing/Filtering media/characters/staff/studios/users/reviews 5 | - Viewing general and user activity feeds 6 | - Composing messages 7 | - Calendar for release schedules 8 | - Customization with different themes and other options 9 | 10 | And more! -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/phoneScreenshots/4.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/phoneScreenshots/5.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/phoneScreenshots/6.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/phoneScreenshots/7.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/phoneScreenshots/8.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/phoneScreenshots/9.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/sevenInchScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/sevenInchScreenshots/1.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/sevenInchScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/sevenInchScreenshots/2.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/sevenInchScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/fastlane/metadata/android/en-US/images/sevenInchScreenshots/3.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | An unofficial AniList client for Android and iOS -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Otraku -------------------------------------------------------------------------------- /flutter_launcher_icons-dev.yaml: -------------------------------------------------------------------------------- 1 | flutter_icons: 2 | ios: true 3 | android: true 4 | image_path: "assets/icons/ios.png" 5 | adaptive_icon_background: "#E3F2FF" 6 | adaptive_icon_foreground: "assets/icons/android.png" -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /ios/Flutter/.last_build_id: -------------------------------------------------------------------------------- 1 | 854473a0cf5d4145988d906afd9efde9 -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 12.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | platform :ios, '12.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | end 36 | 37 | post_install do |installer| 38 | installer.pods_project.targets.each do |target| 39 | flutter_additional_ios_build_settings(target) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /ios/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Flutter (1.0.0) 3 | - flutter_local_notifications (0.0.1): 4 | - Flutter 5 | - flutter_secure_storage (6.0.0): 6 | - Flutter 7 | - path_provider_foundation (0.0.1): 8 | - Flutter 9 | - FlutterMacOS 10 | - sqflite_darwin (0.0.4): 11 | - Flutter 12 | - FlutterMacOS 13 | - url_launcher_ios (0.0.1): 14 | - Flutter 15 | - workmanager (0.0.1): 16 | - Flutter 17 | 18 | DEPENDENCIES: 19 | - Flutter (from `Flutter`) 20 | - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) 21 | - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) 22 | - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) 23 | - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) 24 | - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) 25 | - workmanager (from `.symlinks/plugins/workmanager/ios`) 26 | 27 | EXTERNAL SOURCES: 28 | Flutter: 29 | :path: Flutter 30 | flutter_local_notifications: 31 | :path: ".symlinks/plugins/flutter_local_notifications/ios" 32 | flutter_secure_storage: 33 | :path: ".symlinks/plugins/flutter_secure_storage/ios" 34 | path_provider_foundation: 35 | :path: ".symlinks/plugins/path_provider_foundation/darwin" 36 | sqflite_darwin: 37 | :path: ".symlinks/plugins/sqflite_darwin/darwin" 38 | url_launcher_ios: 39 | :path: ".symlinks/plugins/url_launcher_ios/ios" 40 | workmanager: 41 | :path: ".symlinks/plugins/workmanager/ios" 42 | 43 | SPEC CHECKSUMS: 44 | Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 45 | flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f 46 | flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 47 | path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 48 | sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d 49 | url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe 50 | workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 51 | 52 | PODFILE CHECKSUM: 4e8f8b2be68aeea4c0d5beb6ff1e79fface1d048 53 | 54 | COCOAPODS: 1.11.3 55 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @main 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | 12 | // Workmanager 13 | UIApplication.shared.setMinimumBackgroundFetchInterval(TimeInterval(60*15)) 14 | 15 | // Flutter Local Notifications 16 | if #available(iOS 10.0, *) { 17 | UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate 18 | } 19 | 20 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/AppIcon-dev-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | {"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"}} -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "splash_icon-2.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "splash_icon-1.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "splash_icon.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | 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. -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/splash_icon-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/LaunchImage.imageset/splash_icon-1.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/splash_icon-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/LaunchImage.imageset/splash_icon-2.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/splash_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lotusprey/otraku/e0276fc9c879de654aa4e59564440e0d06dc8dbb/ios/Runner/Assets.xcassets/LaunchImage.imageset/splash_icon.png -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Otraku 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | otraku 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | LSApplicationQueriesSchemes 24 | 25 | https 26 | 27 | CFBundleURLTypes 28 | 29 | 30 | CFBundleTypeRole 31 | Editor 32 | CFBundleURLName 33 | otraku 34 | CFBundleURLSchemes 35 | 36 | app 37 | 38 | 39 | 40 | CFBundleVersion 41 | $(FLUTTER_BUILD_NUMBER) 42 | LSRequiresIPhoneOS 43 | 44 | UIBackgroundModes 45 | 46 | fetch 47 | processing 48 | 49 | UILaunchStoryboardName 50 | LaunchScreen 51 | UIMainStoryboardFile 52 | Main 53 | UISupportedInterfaceOrientations 54 | 55 | UIInterfaceOrientationPortrait 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | UISupportedInterfaceOrientations~ipad 60 | 61 | UIInterfaceOrientationPortrait 62 | UIInterfaceOrientationPortraitUpsideDown 63 | UIInterfaceOrientationLandscapeLeft 64 | UIInterfaceOrientationLandscapeRight 65 | 66 | UIViewControllerBasedStatusBarAppearance 67 | 68 | CADisableMinimumFrameDurationOnPhone 69 | 70 | UIApplicationSupportsIndirectInputEvents 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /lib/extension/build_context_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:otraku/util/routes.dart'; 4 | 5 | extension BuildContextExtension on BuildContext { 6 | void back() => canPop() ? pop() : go(Routes.home()); 7 | } 8 | -------------------------------------------------------------------------------- /lib/extension/color_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | extension ColorExtension on Color { 4 | static Color? fromHexString(String src) { 5 | try { 6 | return Color(int.parse(src.substring(1, 7), radix: 16) + 0xFF000000); 7 | } catch (_) { 8 | return null; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/extension/date_time_extension.dart: -------------------------------------------------------------------------------- 1 | extension DateTimeExtension on DateTime { 2 | int get secondsSinceEpoch => millisecondsSinceEpoch ~/ 1000; 3 | 4 | static DateTime fromSecondsSinceEpoch(int seconds) => 5 | DateTime.fromMillisecondsSinceEpoch(seconds * 1000); 6 | 7 | static DateTime? tryFromSecondsSinceEpoch(int? seconds) => 8 | seconds != null ? fromSecondsSinceEpoch(seconds) : null; 9 | 10 | String formattedDateTimeFromSeconds(bool analogClock) => 11 | '${_weekdayName(weekday)}, $formattedDate, ${formattedTime(analogClock)}'; 12 | 13 | static DateTime? fromFuzzyDate(Map? map) { 14 | if (map?['year'] == null) return null; 15 | return DateTime(map!['year'], map['month'] ?? 1, map['day'] ?? 1); 16 | } 17 | 18 | static String? fuzzyDateString(Map? map) { 19 | if (map == null || map['year'] == null) return null; 20 | 21 | final year = map['year']; 22 | final month = map['month']; 23 | final day = map['day']; 24 | 25 | return '${day != null ? '$day ' : ''}' 26 | '${month != null ? '${monthName(month)} ' : ''}' 27 | '$year'; 28 | } 29 | 30 | Map get fuzzyDate => 31 | {'year': year, 'month': month, 'day': day}; 32 | 33 | String get formattedWithWeekDay => 34 | '$formattedDate - ${_weekdayName(weekday)}'; 35 | 36 | String get formattedDate => '$day ${monthName(month)} $year'; 37 | 38 | String formattedTime(bool analogClock) { 39 | if (analogClock) { 40 | final (overflows, realHour) = 41 | hour > 12 ? (true, hour - 12) : (false, hour); 42 | 43 | return '${realHour < 10 ? 0 : ''}$realHour' 44 | ':${minute < 10 ? 0 : ''}$minute ' 45 | '${overflows ? 'PM' : 'AM'}'; 46 | } 47 | 48 | return '${hour <= 9 ? 0 : ''}$hour' 49 | ':${minute <= 9 ? 0 : ''}$minute'; 50 | } 51 | 52 | String get timeUntil { 53 | int minutes = difference(DateTime.now()).inMinutes; 54 | int hours = minutes ~/ 60; 55 | minutes %= 60; 56 | int days = hours ~/ 24; 57 | hours %= 24; 58 | return '${days < 1 ? "" : "${days}d "}' 59 | '${hours < 1 ? "" : "${hours}h "}' 60 | '${minutes < 1 ? "" : "${minutes}m"}'; 61 | } 62 | 63 | static String monthName(int month) => switch (month) { 64 | 1 => 'Jan', 65 | 2 => 'Feb', 66 | 3 => 'Mar', 67 | 4 => 'Apr', 68 | 5 => 'May', 69 | 6 => 'Jun', 70 | 7 => 'Jul', 71 | 8 => 'Aug', 72 | 9 => 'Sep', 73 | 10 => 'Oct', 74 | 11 => 'Nov', 75 | _ => 'Dec', 76 | }; 77 | 78 | static String _weekdayName(int weekday) => switch (weekday) { 79 | 1 => 'Mon', 80 | 2 => 'Tue', 81 | 3 => 'Wed', 82 | 4 => 'Thu', 83 | 5 => 'Fri', 84 | 6 => 'Sat', 85 | _ => 'Sun', 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /lib/extension/enum_extension.dart: -------------------------------------------------------------------------------- 1 | extension EnumExtension on Iterable { 2 | T? getOrNull(int? index) { 3 | if (index != null && index >= 0 && index < length) { 4 | return elementAt(index); 5 | } 6 | 7 | return null; 8 | } 9 | 10 | T getOrFirst(int? index) { 11 | if (index != null && index >= 0 && index < length) { 12 | return elementAt(index); 13 | } 14 | 15 | return first; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/extension/future_extension.dart: -------------------------------------------------------------------------------- 1 | extension FutureExtension on Future { 2 | Future getErrorOrNull() => 3 | then((_) => null, onError: (e) => e); 4 | } 5 | -------------------------------------------------------------------------------- /lib/extension/iterable_extension.dart: -------------------------------------------------------------------------------- 1 | extension IterableExtension on Iterable { 2 | E? firstWhereOrNull(bool Function(E) test) { 3 | for (E element in this) { 4 | if (test(element)) return element; 5 | } 6 | return null; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/extension/scroll_controller_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | extension ScrollControllerExtension on ScrollController { 4 | /// Scroll to the top with an animation. 5 | Future scrollToTop() async { 6 | if (!hasClients || positions.last.pixels <= 0) return; 7 | 8 | if (positions.last.pixels > 100) positions.last.jumpTo(100); 9 | 10 | await positions.last.animateTo( 11 | 0, 12 | duration: const Duration(milliseconds: 200), 13 | curve: Curves.decelerate, 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/extension/snack_bar_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/services.dart'; 3 | import 'package:url_launcher/url_launcher.dart'; 4 | 5 | extension SnackBarExtension on SnackBar { 6 | static ScaffoldFeatureController show( 7 | BuildContext context, 8 | String text, { 9 | bool canCopyText = false, 10 | }) { 11 | return ScaffoldMessenger.of(context).showSnackBar(SnackBar( 12 | content: Text(text), 13 | behavior: SnackBarBehavior.floating, 14 | duration: const Duration(milliseconds: 2000), 15 | action: canCopyText 16 | ? SnackBarAction( 17 | label: 'Copy', 18 | onPressed: () => Clipboard.setData(ClipboardData(text: text)), 19 | ) 20 | : null, 21 | )); 22 | } 23 | 24 | /// Copy [text] to clipboard and notify with a snackbar. 25 | static void copy(BuildContext context, String text) async { 26 | await Clipboard.setData(ClipboardData(text: text)); 27 | if (context.mounted) show(context, 'Copied'); 28 | } 29 | 30 | /// Launch [link] in the browser or show a snackbar if unsuccessful. 31 | static Future launch(BuildContext context, String link) async { 32 | try { 33 | final ok = await launchUrl( 34 | Uri.parse(link), 35 | mode: link.startsWith("https://anilist.co") 36 | ? LaunchMode.inAppBrowserView 37 | : LaunchMode.externalApplication, 38 | ); 39 | 40 | if (ok) return true; 41 | } catch (_) {} 42 | 43 | if (context.mounted) show(context, 'Could not open link'); 44 | return false; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/extension/string_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:otraku/extension/date_time_extension.dart'; 2 | 3 | extension StringExtension on String { 4 | static String? languageToCode(String? language) => switch (language) { 5 | 'Japanese' => 'JP', 6 | 'Chinese' => 'CN', 7 | 'Korean' => 'KR', 8 | 'French' => 'FR', 9 | 'Spanish' => 'ES', 10 | 'Italian' => 'IT', 11 | 'Portuguese' => 'PT', 12 | 'German' => 'DE', 13 | _ => null, 14 | }; 15 | 16 | static String? tryNoScreamingSnakeCase(dynamic str) => 17 | str is String ? str.noScreamingSnakeCase : null; 18 | 19 | static final _ampersand = '&'.codeUnitAt(0); 20 | static final _hashtag = '#'.codeUnitAt(0); 21 | static final _semicolon = ';'.codeUnitAt(0); 22 | 23 | /// AniList can't handle some unicode characters, so before uploading text, 24 | /// symbols that are too big should be represented as HTML character entity 25 | /// references. Important primarily for emojis, hence the name. 26 | String get withParsedEmojis { 27 | final parsedRunes = []; 28 | for (final c in runes.toList()) { 29 | if (c > 0xFFFF) { 30 | parsedRunes.addAll( 31 | [_ampersand, _hashtag, ...c.toString().codeUnits, _semicolon], 32 | ); 33 | } else { 34 | parsedRunes.add(c); 35 | } 36 | } 37 | 38 | return String.fromCharCodes(parsedRunes); 39 | } 40 | 41 | String get noScreamingSnakeCase => splitMapJoin( 42 | '_', 43 | onMatch: (_) => ' ', 44 | onNonMatch: (s) => s[0].toUpperCase() + s.substring(1).toLowerCase(), 45 | ); 46 | 47 | static String? fromFuzzyDate(Map? map) { 48 | if (map?['year'] == null) return null; 49 | final year = map!['year']; 50 | final month = map['month']; 51 | final day = map['day']; 52 | return '${day != null ? '$day ' : ''}${month != null ? '${DateTimeExtension.monthName(month)} ' : ''}$year'; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/feature/activity/activities_filter_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/activity/activities_filter_model.dart'; 3 | import 'package:otraku/feature/viewer/persistence_provider.dart'; 4 | 5 | final activitiesFilterProvider = NotifierProvider.autoDispose 6 | .family( 7 | ActivitiesFilterNotifier.new, 8 | ); 9 | 10 | class ActivitiesFilterNotifier 11 | extends AutoDisposeFamilyNotifier { 12 | @override 13 | ActivitiesFilter build(arg) => arg == null 14 | ? ref.watch(persistenceProvider.select((s) => s.homeActivitiesFilter)) 15 | : UserActivitiesFilter(ActivityType.values, arg); 16 | 17 | @override 18 | set state(ActivitiesFilter newState) { 19 | if (state == newState) return; 20 | 21 | switch (newState) { 22 | case HomeActivitiesFilter homeActivitiesFilter: 23 | ref 24 | .read(persistenceProvider.notifier) 25 | .setHomeActivitiesFilter(homeActivitiesFilter); 26 | case UserActivitiesFilter _: 27 | super.state = newState; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/feature/calendar/calendar_filter_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/viewer/persistence_provider.dart'; 3 | import 'package:otraku/feature/calendar/calendar_models.dart'; 4 | 5 | final calendarFilterProvider = 6 | NotifierProvider.autoDispose( 7 | CalendarFilterNotifier.new, 8 | ); 9 | 10 | class CalendarFilterNotifier extends AutoDisposeNotifier { 11 | @override 12 | CalendarFilter build() => ref.watch( 13 | persistenceProvider.select((s) => s.calendarFilter), 14 | ); 15 | 16 | @override 17 | set state(CalendarFilter newState) { 18 | ref.read(persistenceProvider.notifier).setCalendarFilter(newState); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/feature/calendar/calendar_filter_sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:otraku/util/theming.dart'; 4 | import 'package:otraku/widget/sheets.dart'; 5 | import 'package:otraku/feature/calendar/calendar_filter_provider.dart'; 6 | import 'package:otraku/feature/calendar/calendar_models.dart'; 7 | import 'package:otraku/widget/input/chip_selector.dart'; 8 | 9 | void showCalendarFilterSheet(BuildContext context, WidgetRef ref) { 10 | final filter = ref.read(calendarFilterProvider); 11 | CalendarSeasonFilter season = filter.season; 12 | CalendarStatusFilter status = filter.status; 13 | 14 | showSheet( 15 | context, 16 | SimpleSheet( 17 | initialHeight: Theming.normalTapTarget * 2 + 18 | MediaQuery.paddingOf(context).bottom + 19 | 40, 20 | builder: (context, scrollCtrl) => ListView( 21 | controller: scrollCtrl, 22 | physics: Theming.bouncyPhysics, 23 | padding: const EdgeInsets.symmetric( 24 | horizontal: Theming.offset, 25 | vertical: 20, 26 | ), 27 | children: [ 28 | ChipSelector( 29 | title: 'Season', 30 | items: CalendarSeasonFilter.values 31 | .skip(1) 32 | .map((v) => (v.label, v)) 33 | .toList(), 34 | value: season != CalendarSeasonFilter.all ? season : null, 35 | onChanged: (v) => season = v ?? CalendarSeasonFilter.all, 36 | ), 37 | ChipSelector( 38 | title: 'Status', 39 | items: CalendarStatusFilter.values 40 | .skip(1) 41 | .map((v) => (v.label, v)) 42 | .toList(), 43 | value: status != CalendarStatusFilter.all ? status : null, 44 | onChanged: (v) => status = v ?? CalendarStatusFilter.all, 45 | ), 46 | ], 47 | ), 48 | ), 49 | ).then((_) { 50 | if (season != filter.season || status != filter.status) { 51 | ref.read(calendarFilterProvider.notifier).state = filter.copyWith( 52 | season: season, 53 | status: status, 54 | ); 55 | } 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /lib/feature/character/character_filter_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:otraku/feature/media/media_models.dart'; 2 | 3 | class CharacterFilter { 4 | CharacterFilter({this.sort = MediaSort.trendingDesc, this.inLists}); 5 | 6 | final MediaSort sort; 7 | final bool? inLists; 8 | 9 | CharacterFilter copyWith({MediaSort? sort, (bool?,)? inLists}) => 10 | CharacterFilter( 11 | sort: sort ?? this.sort, 12 | inLists: inLists == null ? this.inLists : inLists.$1, 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /lib/feature/character/character_filter_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/character/character_filter_model.dart'; 3 | 4 | final characterFilterProvider = NotifierProvider.autoDispose 5 | .family( 6 | CharacterFilterNotifier.new, 7 | ); 8 | 9 | class CharacterFilterNotifier 10 | extends AutoDisposeFamilyNotifier { 11 | @override 12 | CharacterFilter build(arg) => CharacterFilter(); 13 | 14 | @override 15 | set state(CharacterFilter newState) => super.state = newState; 16 | } 17 | -------------------------------------------------------------------------------- /lib/feature/character/character_floating_actions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:ionicons/ionicons.dart'; 4 | import 'package:otraku/feature/character/character_filter_provider.dart'; 5 | import 'package:otraku/widget/input/chip_selector.dart'; 6 | import 'package:otraku/feature/media/media_models.dart'; 7 | import 'package:otraku/util/theming.dart'; 8 | import 'package:otraku/widget/sheets.dart'; 9 | 10 | class CharacterMediaFilterButton extends StatelessWidget { 11 | const CharacterMediaFilterButton(this.id, this.ref); 12 | 13 | final int id; 14 | final WidgetRef ref; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return FloatingActionButton( 19 | tooltip: 'Filter', 20 | child: const Icon(Ionicons.funnel_outline), 21 | onPressed: () { 22 | var filter = ref.read(characterFilterProvider(id)); 23 | 24 | final onDone = (_) => 25 | ref.read(characterFilterProvider(id).notifier).state = filter; 26 | 27 | showSheet( 28 | context, 29 | SimpleSheet( 30 | initialHeight: Theming.normalTapTarget * 2.5 + 31 | MediaQuery.paddingOf(context).bottom + 32 | 40, 33 | builder: (context, scrollCtrl) => ListView( 34 | controller: scrollCtrl, 35 | physics: Theming.bouncyPhysics, 36 | padding: const EdgeInsets.symmetric( 37 | horizontal: Theming.offset, 38 | vertical: 20, 39 | ), 40 | children: [ 41 | ChipSelector.ensureSelected( 42 | title: 'Sort', 43 | items: MediaSort.values.map((v) => (v.label, v)).toList(), 44 | value: filter.sort, 45 | onChanged: (v) => filter = filter.copyWith(sort: v), 46 | ), 47 | ChipSelector( 48 | title: 'List Presence', 49 | items: const [ 50 | ('In Lists', true), 51 | ('Not in Lists', false), 52 | ], 53 | value: filter.inLists, 54 | onChanged: (v) => filter = filter.copyWith( 55 | inLists: (v,), 56 | ), 57 | ), 58 | ], 59 | ), 60 | ), 61 | ).then(onDone); 62 | }, 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/feature/character/character_item_grid.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:otraku/feature/character/character_item_model.dart'; 4 | import 'package:otraku/util/routes.dart'; 5 | import 'package:otraku/util/theming.dart'; 6 | import 'package:otraku/widget/cached_image.dart'; 7 | import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; 8 | 9 | class CharacterItemGrid extends StatelessWidget { 10 | const CharacterItemGrid(this.items); 11 | 12 | final List items; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return SliverGrid( 17 | gridDelegate: const SliverGridDelegateWithMinWidthAndExtraHeight( 18 | minWidth: 100, 19 | extraHeight: 40, 20 | rawHWRatio: Theming.coverHtoWRatio, 21 | ), 22 | delegate: SliverChildBuilderDelegate( 23 | (_, i) => _Tile(items[i]), 24 | childCount: items.length, 25 | ), 26 | ); 27 | } 28 | } 29 | 30 | class _Tile extends StatelessWidget { 31 | const _Tile(this.item); 32 | 33 | final CharacterItem item; 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return InkWell( 38 | borderRadius: Theming.borderRadiusSmall, 39 | onTap: () => context.push(Routes.character(item.id, item.imageUrl)), 40 | child: Column( 41 | children: [ 42 | Expanded( 43 | child: Hero( 44 | tag: item.id, 45 | child: ClipRRect( 46 | borderRadius: Theming.borderRadiusSmall, 47 | child: CachedImage(item.imageUrl), 48 | ), 49 | ), 50 | ), 51 | const SizedBox(height: 5), 52 | SizedBox( 53 | height: 35, 54 | child: Text( 55 | item.name, 56 | maxLines: 2, 57 | overflow: TextOverflow.fade, 58 | style: TextTheme.of(context).bodyMedium, 59 | ), 60 | ), 61 | ], 62 | ), 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/feature/character/character_item_model.dart: -------------------------------------------------------------------------------- 1 | class CharacterItem { 2 | const CharacterItem._({ 3 | required this.id, 4 | required this.name, 5 | required this.imageUrl, 6 | }); 7 | 8 | factory CharacterItem(Map map) => CharacterItem._( 9 | id: map['id'], 10 | name: map['name']['userPreferred'], 11 | imageUrl: map['image']['large'], 12 | ); 13 | 14 | final int id; 15 | final String name; 16 | final String imageUrl; 17 | } 18 | -------------------------------------------------------------------------------- /lib/feature/character/character_manga_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:otraku/feature/character/character_model.dart'; 5 | import 'package:otraku/util/routes.dart'; 6 | import 'package:otraku/widget/grid/mono_relation_grid.dart'; 7 | import 'package:otraku/widget/paged_view.dart'; 8 | import 'package:otraku/feature/character/character_provider.dart'; 9 | 10 | class CharacterMangaSubview extends StatelessWidget { 11 | const CharacterMangaSubview({required this.id, required this.scrollCtrl}); 12 | 13 | final int id; 14 | final ScrollController scrollCtrl; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return PagedView( 19 | scrollCtrl: scrollCtrl, 20 | onRefresh: (invalidate) => invalidate(characterMediaProvider(id)), 21 | provider: characterMediaProvider(id).select( 22 | (s) => s.unwrapPrevious().whenData((data) => data.manga), 23 | ), 24 | onData: (data) => MonoRelationGrid( 25 | items: data.items, 26 | onTap: (item) => context.push( 27 | Routes.media(item.tileId, item.tileImageUrl), 28 | ), 29 | ), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/feature/collection/collection_filter_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/collection/collection_filter_model.dart'; 3 | import 'package:otraku/feature/viewer/persistence_provider.dart'; 4 | import 'package:otraku/feature/collection/collection_models.dart'; 5 | 6 | final collectionFilterProvider = NotifierProvider.autoDispose 7 | .family( 8 | CollectionFilterNotifier.new, 9 | ); 10 | 11 | class CollectionFilterNotifier 12 | extends AutoDisposeFamilyNotifier { 13 | @override 14 | CollectionFilter build(arg) { 15 | final mediaFilter = ref.watch(persistenceProvider.select( 16 | (s) => arg.ofAnime 17 | ? s.animeCollectionMediaFilter 18 | : s.mangaCollectionMediaFilter, 19 | )); 20 | 21 | return CollectionFilter(mediaFilter.copy()); 22 | } 23 | 24 | CollectionFilter update( 25 | CollectionFilter Function(CollectionFilter) callback, 26 | ) => 27 | state = callback(state); 28 | } 29 | -------------------------------------------------------------------------------- /lib/feature/comment/comment_provider.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:collection'; 3 | 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:otraku/extension/future_extension.dart'; 6 | import 'package:otraku/feature/comment/comment_model.dart'; 7 | import 'package:otraku/feature/viewer/repository_provider.dart'; 8 | import 'package:otraku/util/graphql.dart'; 9 | 10 | final commentProvider = 11 | AsyncNotifierProvider.autoDispose.family( 12 | CommentNotifier.new, 13 | ); 14 | 15 | class CommentNotifier extends AutoDisposeFamilyAsyncNotifier { 16 | @override 17 | FutureOr build(int arg) async { 18 | final data = await ref 19 | .read(repositoryProvider) 20 | .request(GqlQuery.comment, {'id': arg}); 21 | 22 | // The response is a list of comments that match the filter criteria. 23 | // Since we're filtering by id, we expect exactly one comment. 24 | final comments = data['ThreadComment']; 25 | if (comments.isEmpty) { 26 | throw Exception('Not Found'); 27 | } 28 | 29 | // The response always starts from the root comment, 30 | // even if a subcomment was requested. 31 | // We search for the requested subcomment with BFS. 32 | final queue = Queue>(); 33 | queue.add(comments[0]); 34 | while (queue.isNotEmpty) { 35 | final comment = queue.removeFirst(); 36 | if (comment['id'] == arg) { 37 | return Comment(comment); 38 | } 39 | 40 | for (final child in comment['childComments'] ?? const []) { 41 | queue.addLast(child); 42 | } 43 | } 44 | 45 | throw Exception('Not Found'); 46 | } 47 | 48 | void edit(Map map) => 49 | state = state.whenData((data) => data.withEditedText(map['comment'])); 50 | 51 | Future toggleCommentLike(int commentId) { 52 | return ref.read(repositoryProvider).request( 53 | GqlMutation.toggleLike, 54 | {'id': commentId, 'type': 'THREAD_COMMENT'}, 55 | ).getErrorOrNull(); 56 | } 57 | 58 | void appendComment(Map map, int parentCommentId) { 59 | final value = state.valueOrNull; 60 | if (value == null) return; 61 | 62 | state = AsyncValue.data( 63 | value.withAppendedChildComment(map, parentCommentId), 64 | ); 65 | } 66 | 67 | Future delete() => ref 68 | .read(repositoryProvider) 69 | .request(GqlMutation.deleteComment, {'id': arg}).getErrorOrNull(); 70 | } 71 | -------------------------------------------------------------------------------- /lib/feature/composition/composition_model.dart: -------------------------------------------------------------------------------- 1 | /// Each type of composition is represented by a different tag class that 2 | /// extends [CompositionTag]. All tags must implement equals and hash for 3 | /// riverpod to work correctly. 4 | sealed class CompositionTag { 5 | const CompositionTag({required this.id}); 6 | 7 | final int? id; 8 | } 9 | 10 | class StatusActivityCompositionTag extends CompositionTag { 11 | const StatusActivityCompositionTag({required super.id}); 12 | 13 | @override 14 | bool operator ==(Object other) => 15 | other is StatusActivityCompositionTag && id == other.id; 16 | 17 | @override 18 | int get hashCode => id.hashCode; 19 | } 20 | 21 | class MessageActivityCompositionTag extends CompositionTag { 22 | const MessageActivityCompositionTag({ 23 | required super.id, 24 | required this.recipientId, 25 | }); 26 | 27 | final int recipientId; 28 | 29 | @override 30 | bool operator ==(Object other) => 31 | other is MessageActivityCompositionTag && 32 | id == other.id && 33 | recipientId == other.recipientId; 34 | 35 | @override 36 | int get hashCode => Object.hash(id, recipientId); 37 | } 38 | 39 | class ActivityReplyCompositionTag extends CompositionTag { 40 | const ActivityReplyCompositionTag({ 41 | required super.id, 42 | required this.activityId, 43 | }); 44 | 45 | final int activityId; 46 | 47 | @override 48 | bool operator ==(Object other) => 49 | other is ActivityReplyCompositionTag && 50 | id == other.id && 51 | activityId == other.activityId; 52 | 53 | @override 54 | int get hashCode => Object.hash(id, activityId); 55 | } 56 | 57 | class CommentCompositionTag extends CompositionTag { 58 | const CommentCompositionTag({ 59 | required this.threadId, 60 | required this.parentCommentId, 61 | }) : super(id: null); 62 | 63 | const CommentCompositionTag.edit({required super.id, required this.threadId}) 64 | : parentCommentId = null; 65 | 66 | final int threadId; 67 | final int? parentCommentId; 68 | 69 | @override 70 | bool operator ==(Object other) => 71 | other is CommentCompositionTag && 72 | id == other.id && 73 | threadId == other.threadId && 74 | parentCommentId == other.parentCommentId; 75 | 76 | @override 77 | int get hashCode => Object.hash(id, threadId, parentCommentId); 78 | } 79 | 80 | class Composition { 81 | Composition(this.text); 82 | 83 | String text; 84 | } 85 | 86 | /// Only used for new message activities, since the user can toggle visibility. 87 | class PrivateComposition extends Composition { 88 | PrivateComposition(super.text, this.isPrivate); 89 | 90 | bool isPrivate; 91 | } 92 | -------------------------------------------------------------------------------- /lib/feature/discover/discover_filter_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/discover/discover_filter_model.dart'; 3 | import 'package:otraku/feature/viewer/persistence_provider.dart'; 4 | 5 | final discoverFilterProvider = 6 | NotifierProvider( 7 | DiscoverFilterNotifier.new, 8 | ); 9 | 10 | class DiscoverFilterNotifier extends Notifier { 11 | @override 12 | DiscoverFilter build() { 13 | final mediaFilter = ref.watch( 14 | persistenceProvider.select((s) => s.discoverMediaFilter), 15 | ); 16 | 17 | final discoverType = ref.watch( 18 | persistenceProvider.select((s) => s.options.discoverType), 19 | ); 20 | 21 | return DiscoverFilter(discoverType, mediaFilter); 22 | } 23 | 24 | @override 25 | DiscoverFilter get state => super.state; 26 | 27 | @override 28 | set state(DiscoverFilter newState) => super.state = newState; 29 | 30 | DiscoverFilter update(DiscoverFilter Function(DiscoverFilter) callback) => 31 | super.state = callback(state); 32 | } 33 | -------------------------------------------------------------------------------- /lib/feature/discover/discover_floating_action.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:ionicons/ionicons.dart'; 4 | import 'package:otraku/feature/discover/discover_filter_provider.dart'; 5 | import 'package:otraku/feature/discover/discover_model.dart'; 6 | import 'package:otraku/widget/input/pill_selector.dart'; 7 | import 'package:otraku/widget/swipe_switcher.dart'; 8 | import 'package:otraku/widget/sheets.dart'; 9 | 10 | class DiscoverFloatingAction extends StatelessWidget { 11 | const DiscoverFloatingAction() : super(key: const Key('switchDiscover')); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return Consumer( 16 | builder: (context, ref, child) { 17 | final type = ref.watch(discoverFilterProvider.select((s) => s.type)); 18 | 19 | return FloatingActionButton( 20 | tooltip: 'Types', 21 | onPressed: () { 22 | showSheet( 23 | context, 24 | SimpleSheet( 25 | initialHeight: PillSelector.expectedMinHeight( 26 | DiscoverType.values.length, 27 | ), 28 | builder: (context, scrollCtrl) => PillSelector( 29 | scrollCtrl: scrollCtrl, 30 | selected: type.index, 31 | items: DiscoverType.values.map((v) => Text(v.label)).toList(), 32 | onTap: (i) { 33 | ref.read(discoverFilterProvider.notifier).update( 34 | (s) => s.copyWith(type: DiscoverType.values[i]), 35 | ); 36 | Navigator.pop(context); 37 | }, 38 | ), 39 | ), 40 | ); 41 | }, 42 | child: SwipeSwitcher( 43 | index: type.index, 44 | onChanged: (index) => ref 45 | .read(discoverFilterProvider.notifier) 46 | .update((s) => s.copyWith(type: DiscoverType.values[index])), 47 | children: 48 | DiscoverType.values.map((v) => Icon(_typeIcon(v))).toList(), 49 | ), 50 | ); 51 | }, 52 | ); 53 | } 54 | 55 | static IconData _typeIcon(DiscoverType type) => switch (type) { 56 | DiscoverType.anime => Ionicons.film_outline, 57 | DiscoverType.manga => Ionicons.book_outline, 58 | DiscoverType.character => Ionicons.man_outline, 59 | DiscoverType.staff => Ionicons.mic_outline, 60 | DiscoverType.studio => Ionicons.business_outline, 61 | DiscoverType.user => Ionicons.person_outline, 62 | DiscoverType.review => Icons.rate_review_outlined, 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /lib/feature/discover/discover_media_simple_grid.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/feature/discover/discover_model.dart'; 3 | import 'package:otraku/feature/media/media_route_tile.dart'; 4 | import 'package:otraku/util/theming.dart'; 5 | import 'package:otraku/widget/cached_image.dart'; 6 | import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; 7 | 8 | class DiscoverMediaSimpleGrid extends StatelessWidget { 9 | const DiscoverMediaSimpleGrid(this.items); 10 | 11 | final List items; 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return SliverGrid( 16 | gridDelegate: const SliverGridDelegateWithMinWidthAndExtraHeight( 17 | minWidth: 100, 18 | extraHeight: 40, 19 | rawHWRatio: Theming.coverHtoWRatio, 20 | ), 21 | delegate: SliverChildBuilderDelegate( 22 | (_, i) => _Tile(items[i]), 23 | childCount: items.length, 24 | ), 25 | ); 26 | } 27 | } 28 | 29 | class _Tile extends StatelessWidget { 30 | const _Tile(this.item); 31 | 32 | final DiscoverMediaItem item; 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | return MediaRouteTile( 37 | id: item.id, 38 | imageUrl: item.imageUrl, 39 | child: Column( 40 | children: [ 41 | Expanded( 42 | child: Hero( 43 | tag: item.id, 44 | child: ClipRRect( 45 | borderRadius: Theming.borderRadiusSmall, 46 | child: CachedImage(item.imageUrl), 47 | ), 48 | ), 49 | ), 50 | const SizedBox(height: 5), 51 | SizedBox( 52 | height: 35, 53 | child: Text( 54 | item.name, 55 | maxLines: 2, 56 | overflow: TextOverflow.fade, 57 | style: TextTheme.of(context).bodyMedium, 58 | ), 59 | ), 60 | ], 61 | ), 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/feature/feed/feed_floating_action.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:otraku/feature/activity/activities_provider.dart'; 4 | import 'package:otraku/feature/composition/composition_model.dart'; 5 | import 'package:otraku/feature/composition/composition_view.dart'; 6 | import 'package:otraku/widget/sheets.dart'; 7 | 8 | class FeedFloatingAction extends StatelessWidget { 9 | const FeedFloatingAction(this.ref) : super(key: const Key('newPost')); 10 | 11 | final WidgetRef ref; 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return FloatingActionButton( 16 | tooltip: 'New Post', 17 | child: const Icon(Icons.edit_outlined), 18 | onPressed: () => showSheet( 19 | context, 20 | CompositionView( 21 | tag: const StatusActivityCompositionTag(id: null), 22 | onSaved: (map) { 23 | ref.read(activitiesProvider(null).notifier).prepend(map); 24 | }, 25 | ), 26 | ), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/feature/feed/feed_top_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:ionicons/ionicons.dart'; 5 | import 'package:otraku/extension/snack_bar_extension.dart'; 6 | import 'package:otraku/feature/activity/activity_filter_sheet.dart'; 7 | import 'package:otraku/feature/settings/settings_provider.dart'; 8 | import 'package:otraku/feature/viewer/persistence_provider.dart'; 9 | import 'package:otraku/util/routes.dart'; 10 | 11 | class FeedTopBarTrailingContent extends StatelessWidget { 12 | const FeedTopBarTrailingContent(); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return Consumer( 17 | builder: (context, ref, _) { 18 | final count = ref.watch( 19 | settingsProvider.select( 20 | (s) => s.valueOrNull?.unreadNotifications ?? 0, 21 | ), 22 | ); 23 | 24 | final openNotifications = ref.watch(viewerIdProvider) != null 25 | ? () { 26 | ref.read(settingsProvider.notifier).clearUnread(); 27 | context.push(Routes.notifications); 28 | } 29 | : () => SnackBarExtension.show( 30 | context, 31 | 'Log in to view notifications', 32 | ); 33 | 34 | Widget notificationIcon = IconButton( 35 | tooltip: 'Notifications', 36 | icon: const Icon(Ionicons.notifications_outline), 37 | onPressed: openNotifications, 38 | ); 39 | 40 | if (count > 0) { 41 | notificationIcon = Badge.count( 42 | count: count, 43 | offset: Offset.zero, 44 | alignment: Alignment.topLeft, 45 | child: notificationIcon, 46 | ); 47 | } 48 | 49 | return Row( 50 | children: [ 51 | IconButton( 52 | tooltip: 'Forum', 53 | icon: const Icon(Ionicons.chatbubbles_outline), 54 | onPressed: () => context.push(Routes.forum), 55 | ), 56 | notificationIcon, 57 | IconButton( 58 | tooltip: 'Filter', 59 | icon: const Icon(Ionicons.funnel_outline), 60 | onPressed: () => showActivityFilterSheet(context, ref, null), 61 | ), 62 | ], 63 | ); 64 | }, 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/feature/forum/forum_filter_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:otraku/extension/iterable_extension.dart'; 2 | 3 | class ForumFilter { 4 | const ForumFilter({ 5 | required this.search, 6 | required this.category, 7 | required this.isSubscribed, 8 | required this.sort, 9 | }); 10 | 11 | final String search; 12 | final ThreadCategory? category; 13 | final bool isSubscribed; 14 | final ThreadSort sort; 15 | 16 | ForumFilter copyWith({ 17 | String? search, 18 | (ThreadCategory?,)? category, 19 | bool? isSubscribed, 20 | ThreadSort? sort, 21 | }) => 22 | ForumFilter( 23 | search: search ?? this.search, 24 | category: category == null ? this.category : category.$1, 25 | isSubscribed: isSubscribed ?? this.isSubscribed, 26 | sort: sort ?? this.sort, 27 | ); 28 | 29 | Map toGraphQlVariables() => { 30 | if (search.isNotEmpty) 'search': search, 31 | if (isSubscribed) 'subscribed': true, 32 | if (category != null) 'categoryId': category!.id, 33 | if (search.isEmpty) 34 | 'sort': sort.value 35 | else 36 | 'sort': ThreadSort.lastCreated.value, 37 | }; 38 | } 39 | 40 | enum ThreadCategory { 41 | general('General', 7), 42 | anime('Anime', 1), 43 | manga('Manga', 2), 44 | lightNovels('Light Novels', 3), 45 | visualNovels('Visual Novels', 4), 46 | gaming('Gaming', 10), 47 | music('Music', 9), 48 | news('News', 8), 49 | releases('Release Discussions', 5), 50 | recommendations('Recommendations', 15), 51 | forumGames('Forum Games', 16), 52 | miscellaneous('Misc', 17), 53 | announcements('Site Announcements', 13), 54 | feedback('Site Feedback', 11), 55 | bugs('Bug Reports', 12), 56 | apps('AniList Apps', 18); 57 | 58 | const ThreadCategory(this.label, this.id); 59 | 60 | final String label; 61 | final int id; 62 | 63 | static ThreadCategory? from(String? label) => 64 | ThreadCategory.values.firstWhereOrNull((v) => v.label == label); 65 | } 66 | 67 | enum ThreadSort { 68 | pinned('Pinned', 'IS_STICKY'), 69 | firstCreated('First Created', 'CREATED_AT'), 70 | lastCreated('Last Created', 'CREATED_AT_DESC'), 71 | lastRepliedTo('Last Replied To', 'REPLIED_AT_DESC'); 72 | 73 | const ThreadSort(this.label, this.value); 74 | 75 | final String label; 76 | final String value; 77 | } 78 | -------------------------------------------------------------------------------- /lib/feature/forum/forum_filter_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/forum/forum_filter_model.dart'; 3 | 4 | final forumFilterProvider = 5 | NotifierProvider.autoDispose( 6 | ForumFilterNotifier.new, 7 | ); 8 | 9 | class ForumFilterNotifier extends AutoDisposeNotifier { 10 | @override 11 | ForumFilter build() => const ForumFilter( 12 | search: '', 13 | category: null, 14 | isSubscribed: false, 15 | sort: ThreadSort.lastRepliedTo, 16 | ); 17 | 18 | void update(ForumFilter Function(ForumFilter) callback) => 19 | state = callback(state); 20 | } 21 | -------------------------------------------------------------------------------- /lib/feature/forum/forum_filter_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:otraku/feature/forum/forum_filter_model.dart'; 4 | import 'package:otraku/feature/forum/forum_filter_provider.dart'; 5 | import 'package:otraku/util/theming.dart'; 6 | import 'package:otraku/widget/input/chip_selector.dart'; 7 | import 'package:otraku/widget/input/stateful_tiles.dart'; 8 | import 'package:otraku/widget/sheets.dart'; 9 | 10 | void showForumFilterSheet(BuildContext context, WidgetRef ref) async { 11 | var filter = ref.read(forumFilterProvider); 12 | 13 | await showSheet( 14 | context, 15 | SimpleSheet( 16 | initialHeight: Theming.normalTapTarget * 4, 17 | builder: (context, scrollCtrl) => ListView( 18 | controller: scrollCtrl, 19 | padding: const EdgeInsets.only(top: Theming.offset), 20 | children: [ 21 | Padding( 22 | padding: const EdgeInsets.symmetric(horizontal: Theming.offset), 23 | child: ChipSelector.ensureSelected( 24 | title: 'Sort', 25 | items: ThreadSort.values.map((v) => (v.label, v)).toList(), 26 | value: filter.sort, 27 | onChanged: (v) => filter = filter.copyWith(sort: v), 28 | ), 29 | ), 30 | Padding( 31 | padding: const EdgeInsets.symmetric(horizontal: Theming.offset), 32 | child: ChipSelector( 33 | title: 'Category', 34 | items: ThreadCategory.values.map((v) => (v.label, v)).toList(), 35 | value: filter.category, 36 | onChanged: (v) => filter = filter.copyWith(category: (v,)), 37 | ), 38 | ), 39 | StatefulSwitchListTile( 40 | title: const Text('Subscribed'), 41 | value: filter.isSubscribed, 42 | onChanged: (v) => filter = filter.copyWith(isSubscribed: v), 43 | ), 44 | ], 45 | ), 46 | ), 47 | ); 48 | 49 | ref.read(forumFilterProvider.notifier).update((_) => filter); 50 | } 51 | -------------------------------------------------------------------------------- /lib/feature/forum/forum_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:otraku/extension/date_time_extension.dart'; 2 | 3 | class ThreadItem { 4 | const ThreadItem._({ 5 | required this.id, 6 | required this.title, 7 | required this.viewCount, 8 | required this.replyCount, 9 | required this.likeCount, 10 | required this.isSubscribed, 11 | required this.isPinned, 12 | required this.isLocked, 13 | required this.userId, 14 | required this.userName, 15 | required this.userAvatar, 16 | required this.userTimestamp, 17 | required this.isUserReplying, 18 | required this.topics, 19 | }); 20 | 21 | factory ThreadItem(Map map) { 22 | final topics = []; 23 | 24 | for (final c in map['categories'] ?? const []) { 25 | topics.add(c['name']); 26 | } 27 | 28 | for (final c in map['mediaCategories'] ?? const []) { 29 | topics.add(c['title']?['userPreferred'] ?? '?'); 30 | } 31 | 32 | final ( 33 | int userId, 34 | String userName, 35 | String userAvatar, 36 | DateTime userTimestamp, 37 | bool isUserReplying, 38 | ) = map['repliedAt'] != null 39 | ? ( 40 | map['replyUser']?['id'] ?? 0, 41 | map['replyUser']?['name'] ?? '?', 42 | map['replyUser']?['avatar']?['large'] ?? '', 43 | DateTimeExtension.fromSecondsSinceEpoch(map['repliedAt']), 44 | true, 45 | ) 46 | : ( 47 | map['user']?['id'] ?? 0, 48 | map['user']?['name'] ?? '?', 49 | map['user']?['avatar']?['large'] ?? '', 50 | DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']), 51 | false, 52 | ); 53 | 54 | return ThreadItem._( 55 | id: map['id'], 56 | title: map['title'] ?? '?', 57 | viewCount: map['viewCount'] ?? 0, 58 | replyCount: map['replyCount'] ?? 0, 59 | likeCount: map['likeCount'] ?? 0, 60 | isSubscribed: map['isSubscribed'] ?? false, 61 | isPinned: map['isSticky'] ?? false, 62 | isLocked: map['isLocked'] ?? false, 63 | userId: userId, 64 | userName: userName, 65 | userAvatar: userAvatar, 66 | userTimestamp: userTimestamp, 67 | isUserReplying: isUserReplying, 68 | topics: topics, 69 | ); 70 | } 71 | 72 | final int id; 73 | final String title; 74 | final int viewCount; 75 | final int replyCount; 76 | final int likeCount; 77 | final bool isSubscribed; 78 | final bool isPinned; 79 | final bool isLocked; 80 | final int userId; 81 | final String userName; 82 | final String userAvatar; 83 | final DateTime userTimestamp; 84 | final bool isUserReplying; 85 | final List topics; 86 | } 87 | -------------------------------------------------------------------------------- /lib/feature/forum/forum_provider.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:otraku/feature/forum/forum_filter_model.dart'; 5 | import 'package:otraku/feature/forum/forum_filter_provider.dart'; 6 | import 'package:otraku/feature/forum/forum_model.dart'; 7 | import 'package:otraku/feature/viewer/repository_provider.dart'; 8 | import 'package:otraku/util/graphql.dart'; 9 | import 'package:otraku/util/paged.dart'; 10 | 11 | final forumProvider = 12 | AsyncNotifierProvider.autoDispose>( 13 | ForumNotifier.new, 14 | ); 15 | 16 | class ForumNotifier extends AutoDisposeAsyncNotifier> { 17 | late ForumFilter _filter; 18 | 19 | @override 20 | FutureOr> build() { 21 | _filter = ref.watch(forumFilterProvider); 22 | return _fetch(const Paged()); 23 | } 24 | 25 | Future fetch() async { 26 | final oldState = state.valueOrNull ?? const Paged(); 27 | if (!oldState.hasNext) return; 28 | state = await AsyncValue.guard(() => _fetch(oldState)); 29 | } 30 | 31 | Future> _fetch(Paged oldState) async { 32 | final data = await ref.read(repositoryProvider).request( 33 | GqlQuery.threadPage, 34 | {'page': oldState.next, ..._filter.toGraphQlVariables()}, 35 | ); 36 | 37 | final items = []; 38 | for (final t in data['Page']['threads']) { 39 | items.add(ThreadItem(t)); 40 | } 41 | 42 | return oldState.withNext( 43 | items, 44 | data['Page']['pageInfo']['hasNextPage'] ?? false, 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/feature/home/home_model.dart: -------------------------------------------------------------------------------- 1 | class Home { 2 | const Home({ 3 | required this.didExpandAnimeCollection, 4 | required this.didExpandMangaCollection, 5 | }); 6 | 7 | /// In preview mode, user's collections first load only current media. 8 | /// The rest is loaded by a manual request from the user 9 | /// and thus the collection "expands". 10 | /// If preview mode is off, collections are auto-expanded 11 | /// and immediately load everything. 12 | final bool didExpandAnimeCollection; 13 | final bool didExpandMangaCollection; 14 | 15 | Home withExpandedCollection(bool ofAnime) => ofAnime 16 | ? Home( 17 | didExpandAnimeCollection: true, 18 | didExpandMangaCollection: didExpandMangaCollection, 19 | ) 20 | : Home( 21 | didExpandAnimeCollection: didExpandAnimeCollection, 22 | didExpandMangaCollection: true, 23 | ); 24 | } 25 | 26 | enum HomeTab { 27 | feed('Feed'), 28 | anime('Anime'), 29 | manga('Manga'), 30 | discover('Discover'), 31 | profile('Profile'); 32 | 33 | const HomeTab(this.label); 34 | 35 | final String label; 36 | } 37 | -------------------------------------------------------------------------------- /lib/feature/home/home_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/viewer/persistence_provider.dart'; 3 | import 'package:otraku/feature/home/home_model.dart'; 4 | 5 | final homeProvider = NotifierProvider.autoDispose( 6 | HomeNotifier.new, 7 | ); 8 | 9 | class HomeNotifier extends AutoDisposeNotifier { 10 | @override 11 | Home build() { 12 | final options = ref.watch(persistenceProvider.select((s) => s.options)); 13 | 14 | return Home( 15 | didExpandAnimeCollection: !options.animeCollectionPreview, 16 | didExpandMangaCollection: !options.mangaCollectionPreview, 17 | ); 18 | } 19 | 20 | void expandCollection(bool ofAnime) => 21 | state = state.withExpandedCollection(ofAnime); 22 | } 23 | -------------------------------------------------------------------------------- /lib/feature/media/media_floating_actions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/feature/edit/edit_view.dart'; 3 | import 'package:otraku/feature/media/media_models.dart'; 4 | import 'package:otraku/widget/sheets.dart'; 5 | 6 | class MediaEditButton extends StatefulWidget { 7 | const MediaEditButton(this.media); 8 | 9 | final Media media; 10 | 11 | @override 12 | State createState() => _MediaEditButtonState(); 13 | } 14 | 15 | class _MediaEditButtonState extends State { 16 | @override 17 | Widget build(BuildContext context) { 18 | final media = widget.media; 19 | return FloatingActionButton( 20 | tooltip: media.entryEdit.listStatus == null ? 'Add' : 'Edit', 21 | child: media.entryEdit.listStatus == null 22 | ? const Icon(Icons.add) 23 | : const Icon(Icons.edit_outlined), 24 | onPressed: () => showSheet( 25 | context, 26 | EditView( 27 | (id: media.info.id, setComplete: false), 28 | callback: (entryEdit) => setState(() => media.entryEdit = entryEdit), 29 | ), 30 | ), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/feature/media/media_item_grid.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/feature/media/media_item_model.dart'; 3 | import 'package:otraku/feature/media/media_route_tile.dart'; 4 | import 'package:otraku/util/theming.dart'; 5 | import 'package:otraku/widget/cached_image.dart'; 6 | import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; 7 | 8 | class MediaItemGrid extends StatelessWidget { 9 | const MediaItemGrid(this.items); 10 | 11 | final List items; 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return SliverGrid( 16 | gridDelegate: const SliverGridDelegateWithMinWidthAndExtraHeight( 17 | minWidth: 100, 18 | extraHeight: 40, 19 | rawHWRatio: Theming.coverHtoWRatio, 20 | ), 21 | delegate: SliverChildBuilderDelegate( 22 | (_, i) => _Tile(items[i]), 23 | childCount: items.length, 24 | ), 25 | ); 26 | } 27 | } 28 | 29 | class _Tile extends StatelessWidget { 30 | const _Tile(this.item); 31 | 32 | final MediaItem item; 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | return MediaRouteTile( 37 | id: item.id, 38 | imageUrl: item.imageUrl, 39 | child: Column( 40 | children: [ 41 | Expanded( 42 | child: Hero( 43 | tag: item.id, 44 | child: ClipRRect( 45 | borderRadius: Theming.borderRadiusSmall, 46 | child: CachedImage(item.imageUrl), 47 | ), 48 | ), 49 | ), 50 | const SizedBox(height: 5), 51 | SizedBox( 52 | height: 35, 53 | child: Text( 54 | item.name, 55 | maxLines: 2, 56 | overflow: TextOverflow.fade, 57 | style: TextTheme.of(context).bodyMedium, 58 | ), 59 | ), 60 | ], 61 | ), 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/feature/media/media_item_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:otraku/feature/viewer/persistence_model.dart'; 2 | 3 | class MediaItem { 4 | const MediaItem._({ 5 | required this.id, 6 | required this.name, 7 | required this.imageUrl, 8 | }); 9 | 10 | factory MediaItem(Map map, ImageQuality imageQuality) => 11 | MediaItem._( 12 | id: map['id'], 13 | name: map['title']['userPreferred'], 14 | imageUrl: map['coverImage'][imageQuality.value], 15 | ); 16 | 17 | final int id; 18 | final String name; 19 | final String imageUrl; 20 | } 21 | -------------------------------------------------------------------------------- /lib/feature/media/media_route_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:otraku/feature/edit/edit_view.dart'; 4 | import 'package:otraku/util/routes.dart'; 5 | import 'package:otraku/util/theming.dart'; 6 | import 'package:otraku/widget/sheets.dart'; 7 | 8 | class MediaRouteTile extends StatelessWidget { 9 | const MediaRouteTile({ 10 | super.key, 11 | required this.id, 12 | required this.imageUrl, 13 | required this.child, 14 | }); 15 | 16 | final int id; 17 | final String? imageUrl; 18 | final Widget child; 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | return InkWell( 23 | borderRadius: Theming.borderRadiusSmall, 24 | onTap: () => context.push(Routes.media(id, imageUrl)), 25 | onLongPress: () => showSheet( 26 | context, 27 | EditView((id: id, setComplete: false)), 28 | ), 29 | child: child, 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/feature/media/media_staff_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:otraku/feature/media/media_models.dart'; 5 | import 'package:otraku/util/routes.dart'; 6 | import 'package:otraku/widget/grid/mono_relation_grid.dart'; 7 | import 'package:otraku/widget/paged_view.dart'; 8 | import 'package:otraku/feature/media/media_provider.dart'; 9 | 10 | class MediaStaffSubview extends StatelessWidget { 11 | const MediaStaffSubview({required this.id, required this.scrollCtrl}); 12 | 13 | final int id; 14 | final ScrollController scrollCtrl; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return PagedView( 19 | scrollCtrl: scrollCtrl, 20 | onRefresh: (invalidate) => invalidate(mediaConnectionsProvider(id)), 21 | provider: mediaConnectionsProvider(id).select( 22 | (s) => s.unwrapPrevious().whenData((data) => data.staff), 23 | ), 24 | onData: (data) => MonoRelationGrid( 25 | items: data.items, 26 | onTap: (item) => context.push( 27 | Routes.staff(item.tileId, item.tileImageUrl), 28 | ), 29 | ), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/feature/media/media_threads_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:otraku/feature/forum/thread_item_list.dart'; 3 | import 'package:otraku/feature/media/media_provider.dart'; 4 | import 'package:otraku/widget/paged_view.dart'; 5 | 6 | class MediaThreadsSubview extends StatelessWidget { 7 | const MediaThreadsSubview({ 8 | required this.id, 9 | required this.scrollCtrl, 10 | required this.analogClock, 11 | }); 12 | 13 | final int id; 14 | final ScrollController scrollCtrl; 15 | final bool analogClock; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return PagedView( 20 | scrollCtrl: scrollCtrl, 21 | onRefresh: (invalidate) => invalidate(mediaThreadsProvider(id)), 22 | provider: mediaThreadsProvider(id), 23 | onData: (data) => ThreadItemList(data.items, analogClock), 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/feature/notification/notifications_filter_model.dart: -------------------------------------------------------------------------------- 1 | enum NotificationsFilter { 2 | all('All'), 3 | replies('Replies'), 4 | activity('Activity'), 5 | forum('Forum'), 6 | airing('Airing'), 7 | follows('Follows'), 8 | media('Media'); 9 | 10 | const NotificationsFilter(this.label); 11 | 12 | final String label; 13 | 14 | List? get vars => switch (this) { 15 | NotificationsFilter.all => null, 16 | NotificationsFilter.replies => const [ 17 | 'ACTIVITY_MESSAGE', 18 | 'ACTIVITY_REPLY', 19 | 'ACTIVITY_REPLY_SUBSCRIBED', 20 | 'ACTIVITY_MENTION', 21 | 'THREAD_COMMENT_REPLY', 22 | 'THREAD_COMMENT_MENTION', 23 | 'THREAD_SUBSCRIBED', 24 | ], 25 | NotificationsFilter.activity => const [ 26 | 'ACTIVITY_MESSAGE', 27 | 'ACTIVITY_REPLY', 28 | 'ACTIVITY_REPLY_SUBSCRIBED', 29 | 'ACTIVITY_MENTION', 30 | 'ACTIVITY_LIKE', 31 | 'ACTIVITY_REPLY_LIKE', 32 | ], 33 | NotificationsFilter.forum => const [ 34 | 'THREAD_COMMENT_REPLY', 35 | 'THREAD_COMMENT_MENTION', 36 | 'THREAD_SUBSCRIBED', 37 | 'THREAD_LIKE', 38 | 'THREAD_COMMENT_LIKE', 39 | ], 40 | NotificationsFilter.airing => const ['AIRING'], 41 | NotificationsFilter.follows => const ['FOLLOWING'], 42 | NotificationsFilter.media => const [ 43 | 'RELATED_MEDIA_ADDITION', 44 | 'MEDIA_DATA_CHANGE', 45 | 'MEDIA_MERGE', 46 | 'MEDIA_DELETION', 47 | ], 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /lib/feature/notification/notifications_filter_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/notification/notifications_filter_model.dart'; 3 | 4 | final notificationsFilterProvider = NotifierProvider.autoDispose< 5 | NotificationsFilterNotifier, NotificationsFilter>( 6 | NotificationsFilterNotifier.new, 7 | ); 8 | 9 | class NotificationsFilterNotifier 10 | extends AutoDisposeNotifier { 11 | @override 12 | NotificationsFilter build() => NotificationsFilter.all; 13 | 14 | @override 15 | NotificationsFilter get state => super.state; 16 | 17 | @override 18 | set state(NotificationsFilter newState) => super.state = newState; 19 | } 20 | -------------------------------------------------------------------------------- /lib/feature/notification/notifications_provider.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:otraku/feature/notification/notifications_filter_model.dart'; 5 | import 'package:otraku/feature/notification/notifications_filter_provider.dart'; 6 | import 'package:otraku/feature/notification/notifications_model.dart'; 7 | import 'package:otraku/feature/viewer/persistence_provider.dart'; 8 | import 'package:otraku/util/paged.dart'; 9 | import 'package:otraku/feature/viewer/repository_provider.dart'; 10 | import 'package:otraku/util/graphql.dart'; 11 | 12 | final notificationsProvider = AsyncNotifierProvider.autoDispose< 13 | NotificationsNotifier, PagedWithTotal>( 14 | NotificationsNotifier.new, 15 | ); 16 | 17 | class NotificationsNotifier 18 | extends AutoDisposeAsyncNotifier> { 19 | late NotificationsFilter filter; 20 | 21 | @override 22 | FutureOr> build() async { 23 | filter = ref.watch(notificationsFilterProvider); 24 | return await _fetch(const PagedWithTotal()); 25 | } 26 | 27 | Future fetch() async { 28 | final oldState = state.valueOrNull ?? const PagedWithTotal(); 29 | if (!oldState.hasNext) return; 30 | state = await AsyncValue.guard(() => _fetch(oldState)); 31 | } 32 | 33 | Future> _fetch( 34 | PagedWithTotal oldState, 35 | ) async { 36 | final data = await ref.read(repositoryProvider).request( 37 | GqlQuery.notifications, 38 | { 39 | 'page': oldState.next, 40 | if (filter == NotificationsFilter.all) ...{ 41 | 'withCount': true, 42 | 'resetCount': true, 43 | } else 44 | 'filter': filter.vars, 45 | }, 46 | ); 47 | 48 | final imageQuality = ref.read(persistenceProvider).options.imageQuality; 49 | 50 | int? unreadCount; 51 | if (filter.index < 1) { 52 | unreadCount = data['Viewer']['unreadNotificationCount'] ?? 0; 53 | } 54 | 55 | final items = []; 56 | for (final n in data['Page']['notifications']) { 57 | final item = SiteNotification.maybe(n, imageQuality); 58 | if (item != null) items.add(item); 59 | } 60 | 61 | return oldState.withNext( 62 | items, 63 | data['Page']['pageInfo']['hasNextPage'] ?? false, 64 | unreadCount, 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/feature/review/review_header.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:otraku/feature/review/review_models.dart'; 4 | import 'package:otraku/util/routes.dart'; 5 | import 'package:otraku/widget/layout/content_header.dart'; 6 | 7 | class ReviewHeader extends StatelessWidget { 8 | const ReviewHeader({ 9 | required this.id, 10 | required this.review, 11 | required this.bannerUrl, 12 | }); 13 | 14 | final int id; 15 | final Review? review; 16 | final String? bannerUrl; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return CustomContentHeader( 21 | title: review?.mediaTitle, 22 | siteUrl: review?.siteUrl, 23 | bannerUrl: review?.banner ?? bannerUrl, 24 | content: PreferredSize( 25 | preferredSize: const Size.fromHeight(100), 26 | child: Column( 27 | crossAxisAlignment: CrossAxisAlignment.stretch, 28 | children: review != null 29 | ? [ 30 | Flexible( 31 | child: GestureDetector( 32 | onTap: () => context.push( 33 | Routes.media(review!.mediaId, review!.mediaCover), 34 | ), 35 | child: Text( 36 | review!.mediaTitle, 37 | overflow: TextOverflow.fade, 38 | textAlign: TextAlign.center, 39 | style: TextTheme.of(context).titleLarge, 40 | ), 41 | ), 42 | ), 43 | Flexible( 44 | child: GestureDetector( 45 | behavior: HitTestBehavior.opaque, 46 | onTap: () => context.push( 47 | Routes.user(review!.userId, review!.userAvatar), 48 | ), 49 | child: Text.rich( 50 | textAlign: TextAlign.center, 51 | TextSpan( 52 | style: TextTheme.of(context).titleMedium, 53 | children: [ 54 | TextSpan( 55 | text: 'review by ', 56 | style: TextTheme.of(context).labelMedium, 57 | ), 58 | TextSpan(text: review!.userName), 59 | ], 60 | ), 61 | ), 62 | ), 63 | ), 64 | ] 65 | : const [], 66 | ), 67 | ), 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/feature/review/review_provider.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:otraku/extension/future_extension.dart'; 5 | import 'package:otraku/feature/review/review_models.dart'; 6 | import 'package:otraku/feature/viewer/persistence_provider.dart'; 7 | import 'package:otraku/feature/viewer/repository_provider.dart'; 8 | import 'package:otraku/util/graphql.dart'; 9 | 10 | final reviewProvider = 11 | AsyncNotifierProvider.autoDispose.family( 12 | ReviewNotifier.new, 13 | ); 14 | 15 | class ReviewNotifier extends AutoDisposeFamilyAsyncNotifier { 16 | @override 17 | FutureOr build(arg) async { 18 | final data = await ref 19 | .read(repositoryProvider) 20 | .request(GqlQuery.review, {'id': arg}); 21 | 22 | final options = ref.watch(persistenceProvider.select((s) => s.options)); 23 | 24 | return Review(data['Review'], options.imageQuality, options.analogClock); 25 | } 26 | 27 | Future rate(bool? rating) { 28 | return ref.read(repositoryProvider).request( 29 | GqlMutation.rateReview, 30 | { 31 | 'id': arg, 32 | 'rating': rating == null 33 | ? 'NO_VOTE' 34 | : rating 35 | ? 'UP_VOTE' 36 | : 'DOWN_VOTE', 37 | }, 38 | ).getErrorOrNull(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/feature/review/reviews_filter_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/review/review_models.dart'; 3 | 4 | final reviewsFilterProvider = NotifierProvider.autoDispose 5 | .family( 6 | ReviewsFilterNotifier.new, 7 | ); 8 | 9 | class ReviewsFilterNotifier 10 | extends AutoDisposeFamilyNotifier { 11 | @override 12 | ReviewsFilter build(arg) => const ReviewsFilter(); 13 | 14 | @override 15 | set state(ReviewsFilter newState) => super.state = newState; 16 | } 17 | -------------------------------------------------------------------------------- /lib/feature/review/reviews_filter_sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/util/theming.dart'; 3 | import 'package:otraku/widget/sheets.dart'; 4 | import 'package:otraku/widget/input/chip_selector.dart'; 5 | import 'package:otraku/feature/media/media_models.dart'; 6 | import 'package:otraku/feature/review/review_models.dart'; 7 | 8 | Future showReviewsFilterSheet({ 9 | required BuildContext context, 10 | required ReviewsFilter filter, 11 | required void Function(ReviewsFilter) onDone, 12 | }) { 13 | return showSheet( 14 | context, 15 | SimpleSheet( 16 | initialHeight: Theming.minTapTarget * 3.5, 17 | builder: (context, scrollCtrl) => ListView( 18 | controller: scrollCtrl, 19 | physics: Theming.bouncyPhysics, 20 | padding: const EdgeInsets.symmetric( 21 | horizontal: Theming.offset, 22 | vertical: 20, 23 | ), 24 | children: [ 25 | ChipSelector.ensureSelected( 26 | title: 'Sort', 27 | items: ReviewsSort.values.map((v) => (v.label, v)).toList(), 28 | value: filter.sort, 29 | onChanged: (v) => filter = filter.copyWith(sort: v), 30 | ), 31 | ChipSelector( 32 | title: 'Media Type', 33 | items: MediaType.values.map((v) => (v.label, v)).toList(), 34 | value: filter.mediaType, 35 | onChanged: (v) => filter = filter.copyWith(mediaType: (v,)), 36 | ), 37 | ], 38 | ), 39 | ), 40 | ).then((_) => onDone(filter)); 41 | } 42 | -------------------------------------------------------------------------------- /lib/feature/review/reviews_provider.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:otraku/util/paged.dart'; 5 | import 'package:otraku/feature/viewer/repository_provider.dart'; 6 | import 'package:otraku/util/graphql.dart'; 7 | import 'package:otraku/feature/review/review_models.dart'; 8 | import 'package:otraku/feature/review/reviews_filter_provider.dart'; 9 | 10 | final reviewsProvider = AsyncNotifierProvider.autoDispose 11 | .family, int>( 12 | ReviewsNotifier.new, 13 | ); 14 | 15 | class ReviewsNotifier 16 | extends AutoDisposeFamilyAsyncNotifier, int> { 17 | late ReviewsFilter filter; 18 | 19 | @override 20 | FutureOr> build(arg) { 21 | filter = ref.watch(reviewsFilterProvider(arg)); 22 | return _fetch(const PagedWithTotal()); 23 | } 24 | 25 | Future fetch() async { 26 | final oldState = state.valueOrNull ?? const PagedWithTotal(); 27 | if (!oldState.hasNext) return; 28 | state = await AsyncValue.guard(() => _fetch(oldState)); 29 | } 30 | 31 | Future> _fetch( 32 | PagedWithTotal oldState, 33 | ) async { 34 | final data = await ref.read(repositoryProvider).request( 35 | GqlQuery.reviewPage, 36 | { 37 | 'userId': arg, 38 | 'page': oldState.next, 39 | 'sort': filter.sort.value, 40 | if (filter.mediaType != null) 'mediaType': filter.mediaType!.value, 41 | }, 42 | ); 43 | 44 | final items = []; 45 | for (final r in data['Page']['reviews']) { 46 | items.add(ReviewItem(r)); 47 | } 48 | 49 | return oldState.withNext( 50 | items, 51 | data['Page']['pageInfo']['hasNextPage'] ?? false, 52 | data['Page']['pageInfo']['total'] ?? oldState.total, 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/feature/settings/settings_notifications_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/util/theming.dart'; 3 | import 'package:otraku/widget/input/stateful_tiles.dart'; 4 | import 'package:otraku/feature/settings/settings_model.dart'; 5 | 6 | class SettingsNotificationsSubview extends StatelessWidget { 7 | const SettingsNotificationsSubview(this.scrollCtrl, this.settings); 8 | 9 | final ScrollController scrollCtrl; 10 | final Settings settings; 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final listPadding = MediaQuery.paddingOf(context); 15 | 16 | return ListView.builder( 17 | controller: scrollCtrl, 18 | padding: EdgeInsets.only( 19 | top: listPadding.top + Theming.offset, 20 | bottom: listPadding.bottom + Theming.offset, 21 | ), 22 | itemCount: settings.notificationOptions.length, 23 | itemBuilder: (context, i) { 24 | final e = settings.notificationOptions.entries.elementAt(i); 25 | 26 | return StatefulCheckboxListTile( 27 | title: Text(e.key.label), 28 | value: e.value, 29 | onChanged: (v) => settings.notificationOptions[e.key] = v!, 30 | ); 31 | }, 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/feature/social/social_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:otraku/feature/comment/comment_model.dart'; 2 | import 'package:otraku/feature/forum/forum_model.dart'; 3 | import 'package:otraku/feature/user/user_item_model.dart'; 4 | import 'package:otraku/util/paged.dart'; 5 | 6 | class Social { 7 | const Social({ 8 | this.following = const PagedWithTotal(), 9 | this.followers = const PagedWithTotal(), 10 | this.threads = const PagedWithTotal(), 11 | this.comments = const PagedWithTotal(), 12 | }); 13 | 14 | final PagedWithTotal following; 15 | final PagedWithTotal followers; 16 | final PagedWithTotal threads; 17 | final PagedWithTotal comments; 18 | 19 | int getCount(SocialTab tab) => switch (tab) { 20 | SocialTab.following => following.total, 21 | SocialTab.followers => followers.total, 22 | SocialTab.threads => threads.total, 23 | SocialTab.comments => comments.total, 24 | }; 25 | } 26 | 27 | enum SocialTab { 28 | following, 29 | followers, 30 | threads, 31 | comments; 32 | 33 | String get title => switch (this) { 34 | SocialTab.following => 'Following', 35 | SocialTab.followers => 'Followers', 36 | SocialTab.threads => 'Threads', 37 | SocialTab.comments => 'Comments', 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /lib/feature/staff/staff_characters_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:otraku/feature/staff/staff_model.dart'; 5 | import 'package:otraku/util/routes.dart'; 6 | import 'package:otraku/widget/grid/dual_relation_grid.dart'; 7 | import 'package:otraku/widget/paged_view.dart'; 8 | import 'package:otraku/feature/staff/staff_provider.dart'; 9 | 10 | class StaffCharactersSubview extends StatelessWidget { 11 | const StaffCharactersSubview({ 12 | required this.id, 13 | required this.scrollCtrl, 14 | }); 15 | 16 | final int id; 17 | final ScrollController scrollCtrl; 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return PagedView<(StaffRelatedItem, StaffRelatedItem)>( 22 | scrollCtrl: scrollCtrl, 23 | onRefresh: (invalidate) => invalidate(staffRelationsProvider(id)), 24 | provider: staffRelationsProvider(id).select( 25 | (s) => s.unwrapPrevious().whenData((data) => data.charactersAndMedia), 26 | ), 27 | onData: (data) => DualRelationGrid( 28 | items: data.items, 29 | onTapPrimary: (item) => context.push( 30 | Routes.character(item.tileId, item.tileImageUrl), 31 | ), 32 | onTapSecondary: (item) => context.push( 33 | Routes.media(item.tileId, item.tileImageUrl), 34 | ), 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/feature/staff/staff_filter_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:otraku/feature/media/media_models.dart'; 2 | 3 | class StaffFilter { 4 | StaffFilter({ 5 | this.sort = MediaSort.startDateDesc, 6 | this.ofAnime, 7 | this.inLists, 8 | }); 9 | 10 | final MediaSort sort; 11 | final bool? ofAnime; 12 | final bool? inLists; 13 | 14 | StaffFilter copyWith({ 15 | MediaSort? sort, 16 | (bool?,)? ofAnime, 17 | (bool?,)? inLists, 18 | }) => 19 | StaffFilter( 20 | sort: sort ?? this.sort, 21 | ofAnime: ofAnime == null ? this.ofAnime : ofAnime.$1, 22 | inLists: inLists == null ? this.inLists : inLists.$1, 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /lib/feature/staff/staff_filter_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/staff/staff_filter_model.dart'; 3 | 4 | final staffFilterProvider = 5 | NotifierProvider.autoDispose.family( 6 | StaffFilterNotifier.new, 7 | ); 8 | 9 | class StaffFilterNotifier extends AutoDisposeFamilyNotifier { 10 | @override 11 | StaffFilter build(arg) => StaffFilter(); 12 | 13 | @override 14 | set state(StaffFilter newState) => super.state = newState; 15 | } 16 | -------------------------------------------------------------------------------- /lib/feature/staff/staff_floating_actions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:ionicons/ionicons.dart'; 4 | import 'package:otraku/widget/input/chip_selector.dart'; 5 | import 'package:otraku/feature/media/media_models.dart'; 6 | import 'package:otraku/feature/staff/staff_filter_provider.dart'; 7 | import 'package:otraku/util/theming.dart'; 8 | import 'package:otraku/widget/sheets.dart'; 9 | 10 | class StaffFilterButton extends StatelessWidget { 11 | const StaffFilterButton(this.id, this.ref) 12 | : super(key: const Key('filterStaff')); 13 | 14 | final int id; 15 | final WidgetRef ref; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return FloatingActionButton( 20 | tooltip: 'Filter', 21 | heroTag: 'filter', 22 | child: const Icon(Ionicons.funnel_outline), 23 | onPressed: () { 24 | var filter = ref.read(staffFilterProvider(id)); 25 | 26 | final onDone = 27 | (_) => ref.read(staffFilterProvider(id).notifier).state = filter; 28 | 29 | showSheet( 30 | context, 31 | SimpleSheet( 32 | initialHeight: Theming.normalTapTarget * 4 + 33 | MediaQuery.paddingOf(context).bottom + 34 | 40, 35 | builder: (context, scrollCtrl) => ListView( 36 | controller: scrollCtrl, 37 | physics: Theming.bouncyPhysics, 38 | padding: const EdgeInsets.symmetric( 39 | horizontal: Theming.offset, 40 | vertical: 20, 41 | ), 42 | children: [ 43 | ChipSelector.ensureSelected( 44 | title: 'Sort', 45 | items: MediaSort.values.map((v) => (v.label, v)).toList(), 46 | value: filter.sort, 47 | onChanged: (v) => filter = filter.copyWith(sort: v), 48 | ), 49 | ChipSelector( 50 | title: 'Type', 51 | items: const [('Anime', true), ('Manga', false)], 52 | value: filter.ofAnime, 53 | onChanged: (v) => filter = filter.copyWith(ofAnime: (v,)), 54 | ), 55 | const SizedBox(height: Theming.offset), 56 | ChipSelector( 57 | title: 'List Presence', 58 | items: const [ 59 | ('In Lists', true), 60 | ('Not in Lists', false), 61 | ], 62 | value: filter.inLists, 63 | onChanged: (v) => filter = filter.copyWith(inLists: (v,)), 64 | ), 65 | ], 66 | ), 67 | ), 68 | ).then(onDone); 69 | }, 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/feature/staff/staff_item_grid.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:otraku/feature/staff/staff_item_model.dart'; 4 | import 'package:otraku/util/routes.dart'; 5 | import 'package:otraku/util/theming.dart'; 6 | import 'package:otraku/widget/cached_image.dart'; 7 | import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; 8 | 9 | class StaffItemGrid extends StatelessWidget { 10 | const StaffItemGrid(this.items); 11 | 12 | final List items; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return SliverGrid( 17 | gridDelegate: const SliverGridDelegateWithMinWidthAndExtraHeight( 18 | minWidth: 100, 19 | extraHeight: 40, 20 | rawHWRatio: Theming.coverHtoWRatio, 21 | ), 22 | delegate: SliverChildBuilderDelegate( 23 | (_, i) => _Tile(items[i]), 24 | childCount: items.length, 25 | ), 26 | ); 27 | } 28 | } 29 | 30 | class _Tile extends StatelessWidget { 31 | const _Tile(this.item); 32 | 33 | final StaffItem item; 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return InkWell( 38 | borderRadius: Theming.borderRadiusSmall, 39 | onTap: () => context.push(Routes.staff(item.id, item.imageUrl)), 40 | child: Column( 41 | children: [ 42 | Expanded( 43 | child: Hero( 44 | tag: item.id, 45 | child: ClipRRect( 46 | borderRadius: Theming.borderRadiusSmall, 47 | child: CachedImage(item.imageUrl), 48 | ), 49 | ), 50 | ), 51 | const SizedBox(height: 5), 52 | SizedBox( 53 | height: 35, 54 | child: Text( 55 | item.name, 56 | maxLines: 2, 57 | overflow: TextOverflow.fade, 58 | style: TextTheme.of(context).bodyMedium, 59 | ), 60 | ), 61 | ], 62 | ), 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/feature/staff/staff_item_model.dart: -------------------------------------------------------------------------------- 1 | class StaffItem { 2 | const StaffItem._({ 3 | required this.id, 4 | required this.name, 5 | required this.imageUrl, 6 | }); 7 | 8 | factory StaffItem(Map map) => StaffItem._( 9 | id: map['id'], 10 | name: map['name']['userPreferred'], 11 | imageUrl: map['image']['large'], 12 | ); 13 | 14 | final int id; 15 | final String name; 16 | final String imageUrl; 17 | } 18 | -------------------------------------------------------------------------------- /lib/feature/staff/staff_roles_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:go_router/go_router.dart'; 4 | import 'package:otraku/feature/staff/staff_model.dart'; 5 | import 'package:otraku/util/routes.dart'; 6 | import 'package:otraku/widget/grid/mono_relation_grid.dart'; 7 | import 'package:otraku/widget/paged_view.dart'; 8 | import 'package:otraku/feature/staff/staff_provider.dart'; 9 | 10 | class StaffRolesSubview extends StatelessWidget { 11 | const StaffRolesSubview({ 12 | required this.id, 13 | required this.scrollCtrl, 14 | }); 15 | 16 | final int id; 17 | final ScrollController scrollCtrl; 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return PagedView( 22 | scrollCtrl: scrollCtrl, 23 | onRefresh: (invalidate) => invalidate(staffRelationsProvider(id)), 24 | provider: staffRelationsProvider(id).select( 25 | (s) => s.unwrapPrevious().whenData((data) => data.roles), 26 | ), 27 | onData: (data) => MonoRelationGrid( 28 | items: data.items, 29 | onTap: (item) => context.push( 30 | Routes.media(item.tileId, item.tileImageUrl), 31 | ), 32 | ), 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/feature/studio/studio_filter_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:otraku/feature/media/media_models.dart'; 2 | 3 | class StudioFilter { 4 | StudioFilter({ 5 | this.sort = MediaSort.startDateDesc, 6 | this.inLists, 7 | this.isMain, 8 | }); 9 | 10 | final MediaSort sort; 11 | final bool? inLists; 12 | final bool? isMain; 13 | 14 | StudioFilter copyWith({ 15 | MediaSort? sort, 16 | (bool?,)? inLists, 17 | (bool?,)? isMain, 18 | }) => 19 | StudioFilter( 20 | sort: sort ?? this.sort, 21 | inLists: inLists == null ? this.inLists : inLists.$1, 22 | isMain: isMain == null ? this.isMain : isMain.$1, 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /lib/feature/studio/studio_filter_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/studio/studio_filter_model.dart'; 3 | 4 | final studioFilterProvider = NotifierProvider.autoDispose 5 | .family( 6 | StudioFilterNotifier.new, 7 | ); 8 | 9 | class StudioFilterNotifier 10 | extends AutoDisposeFamilyNotifier { 11 | @override 12 | StudioFilter build(arg) => StudioFilter(); 13 | 14 | @override 15 | set state(StudioFilter newState) => super.state = newState; 16 | } 17 | -------------------------------------------------------------------------------- /lib/feature/studio/studio_floating_actions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:ionicons/ionicons.dart'; 4 | import 'package:otraku/widget/input/chip_selector.dart'; 5 | import 'package:otraku/feature/media/media_models.dart'; 6 | import 'package:otraku/feature/studio/studio_filter_provider.dart'; 7 | import 'package:otraku/util/theming.dart'; 8 | import 'package:otraku/widget/sheets.dart'; 9 | 10 | class StudioFilterButton extends StatelessWidget { 11 | const StudioFilterButton(this.id, this.ref); 12 | 13 | final int id; 14 | final WidgetRef ref; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return FloatingActionButton( 19 | tooltip: 'Filter', 20 | heroTag: 'filter', 21 | child: const Icon(Ionicons.funnel_outline), 22 | onPressed: () { 23 | var filter = ref.read(studioFilterProvider(id)); 24 | 25 | final onDone = 26 | (_) => ref.read(studioFilterProvider(id).notifier).state = filter; 27 | 28 | showSheet( 29 | context, 30 | SimpleSheet( 31 | initialHeight: Theming.normalTapTarget * 4 + 32 | MediaQuery.paddingOf(context).bottom + 33 | 40, 34 | builder: (context, scrollCtrl) => ListView( 35 | controller: scrollCtrl, 36 | physics: Theming.bouncyPhysics, 37 | padding: const EdgeInsets.symmetric( 38 | horizontal: Theming.offset, 39 | vertical: 20, 40 | ), 41 | children: [ 42 | ChipSelector.ensureSelected( 43 | title: 'Sort', 44 | items: MediaSort.values.map((v) => (v.label, v)).toList(), 45 | value: filter.sort, 46 | onChanged: (v) => filter = filter.copyWith(sort: v), 47 | ), 48 | ChipSelector( 49 | title: 'List Presence', 50 | items: const [ 51 | ('In Lists', true), 52 | ('Not in Lists', false), 53 | ], 54 | value: filter.inLists, 55 | onChanged: (v) => filter = filter.copyWith(inLists: (v,)), 56 | ), 57 | ChipSelector( 58 | title: 'Main Studio', 59 | items: const [('Is Main', true), ('Is Not Main', false)], 60 | value: filter.isMain, 61 | onChanged: (v) => filter = filter.copyWith(isMain: (v,)), 62 | ), 63 | ], 64 | ), 65 | ), 66 | ).then(onDone); 67 | }, 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/feature/studio/studio_item_grid.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:otraku/feature/studio/studio_item_model.dart'; 4 | import 'package:otraku/util/routes.dart'; 5 | import 'package:otraku/util/theming.dart'; 6 | import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; 7 | 8 | class StudioItemGrid extends StatelessWidget { 9 | const StudioItemGrid(this.items); 10 | 11 | final List items; 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return SliverGrid( 16 | gridDelegate: const SliverGridDelegateWithMinWidthAndFixedHeight( 17 | minWidth: 230, 18 | height: 60, 19 | mainAxisSpacing: 0, 20 | crossAxisSpacing: 0, 21 | ), 22 | delegate: SliverChildBuilderDelegate( 23 | childCount: items.length, 24 | (_, i) => InkWell( 25 | borderRadius: Theming.borderRadiusSmall, 26 | onTap: () => context.push(Routes.studio(items[i].id, items[i].name)), 27 | child: Padding( 28 | padding: const EdgeInsets.symmetric( 29 | horizontal: Theming.offset, 30 | vertical: Theming.offset / 2, 31 | ), 32 | child: Align( 33 | alignment: Alignment.centerLeft, 34 | child: Hero( 35 | tag: items[i].id, 36 | child: Text( 37 | items[i].name, 38 | maxLines: 2, 39 | overflow: TextOverflow.fade, 40 | style: TextTheme.of(context).titleLarge, 41 | ), 42 | ), 43 | ), 44 | ), 45 | ), 46 | ), 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/feature/studio/studio_item_model.dart: -------------------------------------------------------------------------------- 1 | class StudioItem { 2 | const StudioItem._({required this.id, required this.name}); 3 | 4 | factory StudioItem(Map map) => 5 | StudioItem._(id: map['id'], name: map['name']); 6 | 7 | final int id; 8 | final String name; 9 | } 10 | -------------------------------------------------------------------------------- /lib/feature/studio/studio_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:otraku/extension/date_time_extension.dart'; 2 | import 'package:otraku/feature/collection/collection_models.dart'; 3 | import 'package:otraku/feature/media/media_models.dart'; 4 | import 'package:otraku/feature/viewer/persistence_model.dart'; 5 | 6 | class Studio { 7 | Studio._({ 8 | required this.id, 9 | required this.name, 10 | required this.siteUrl, 11 | required this.favorites, 12 | required this.isFavorite, 13 | }); 14 | 15 | factory Studio(Map map) => Studio._( 16 | id: map['id'], 17 | name: map['name'], 18 | siteUrl: map['siteUrl'], 19 | favorites: map['favourites'] ?? 0, 20 | isFavorite: map['isFavourite'] ?? false, 21 | ); 22 | 23 | final int id; 24 | final String name; 25 | final String siteUrl; 26 | final int favorites; 27 | bool isFavorite; 28 | } 29 | 30 | class StudioMedia { 31 | const StudioMedia._({ 32 | required this.id, 33 | required this.title, 34 | required this.cover, 35 | required this.format, 36 | required this.releaseStatus, 37 | required this.weightedAverageScore, 38 | required this.entryStatus, 39 | required this.startDate, 40 | }); 41 | 42 | factory StudioMedia(Map map, ImageQuality imageQuality) => 43 | StudioMedia._( 44 | id: map['id'], 45 | title: map['title']['userPreferred'], 46 | cover: map['coverImage'][imageQuality.value], 47 | format: MediaFormat.from(map['format']), 48 | releaseStatus: ReleaseStatus.from(map['status']), 49 | weightedAverageScore: map['averageScore'] ?? 0, 50 | entryStatus: ListStatus.from(map['mediaListEntry']?['status']), 51 | startDate: DateTimeExtension.fuzzyDateString(map['startDate']), 52 | ); 53 | 54 | final int id; 55 | final String title; 56 | final String cover; 57 | final MediaFormat? format; 58 | final ReleaseStatus? releaseStatus; 59 | final int weightedAverageScore; 60 | final ListStatus? entryStatus; 61 | final String? startDate; 62 | } 63 | -------------------------------------------------------------------------------- /lib/feature/studio/studio_provider.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:otraku/extension/future_extension.dart'; 5 | import 'package:otraku/feature/studio/studio_filter_model.dart'; 6 | import 'package:otraku/feature/viewer/persistence_provider.dart'; 7 | import 'package:otraku/util/paged.dart'; 8 | import 'package:otraku/feature/studio/studio_filter_provider.dart'; 9 | import 'package:otraku/feature/studio/studio_model.dart'; 10 | import 'package:otraku/feature/viewer/repository_provider.dart'; 11 | import 'package:otraku/util/graphql.dart'; 12 | 13 | final studioProvider = 14 | AsyncNotifierProvider.autoDispose.family( 15 | StudioNotifier.new, 16 | ); 17 | 18 | final studioMediaProvider = AsyncNotifierProvider.autoDispose 19 | .family, int>( 20 | StudioMediaNotifier.new, 21 | ); 22 | 23 | class StudioNotifier extends AutoDisposeFamilyAsyncNotifier { 24 | @override 25 | FutureOr build(arg) async { 26 | final data = await ref 27 | .read(repositoryProvider) 28 | .request(GqlQuery.studio, {'id': arg, 'withInfo': true}); 29 | return Studio(data['Studio']); 30 | } 31 | 32 | Future toggleFavorite() { 33 | return ref.read(repositoryProvider).request( 34 | GqlMutation.toggleFavorite, 35 | {'studio': arg}, 36 | ).getErrorOrNull(); 37 | } 38 | } 39 | 40 | class StudioMediaNotifier 41 | extends AutoDisposeFamilyAsyncNotifier, int> { 42 | late StudioFilter filter; 43 | 44 | @override 45 | FutureOr> build(arg) async { 46 | filter = ref.watch(studioFilterProvider(arg)); 47 | return await _fetch(const Paged()); 48 | } 49 | 50 | Future fetch() async { 51 | final oldState = state.valueOrNull ?? const Paged(); 52 | if (!oldState.hasNext) return; 53 | state = await AsyncValue.guard(() => _fetch(oldState)); 54 | } 55 | 56 | Future> _fetch(Paged oldState) async { 57 | final data = await ref.read(repositoryProvider).request(GqlQuery.studio, { 58 | 'id': arg, 59 | 'withMedia': true, 60 | 'page': oldState.next, 61 | 'sort': filter.sort.value, 62 | 'onList': filter.inLists, 63 | if (filter.isMain != null) 'isMain': filter.isMain, 64 | }); 65 | 66 | final imageQuality = ref.read(persistenceProvider).options.imageQuality; 67 | final map = data['Studio']['media']; 68 | final items = []; 69 | for (final m in map['nodes']) { 70 | items.add(StudioMedia(m, imageQuality)); 71 | } 72 | 73 | return oldState.withNext(items, map['pageInfo']['hasNextPage'] ?? false); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/feature/tag/tag_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/util/graphql.dart'; 3 | import 'package:otraku/feature/tag/tag_model.dart'; 4 | import 'package:otraku/feature/viewer/repository_provider.dart'; 5 | 6 | final tagsProvider = FutureProvider( 7 | (ref) async => TagCollection( 8 | await ref.read(repositoryProvider).request(GqlQuery.genresAndTags), 9 | ), 10 | ); 11 | -------------------------------------------------------------------------------- /lib/feature/user/user_item_grid.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:go_router/go_router.dart'; 3 | import 'package:otraku/feature/user/user_item_model.dart'; 4 | import 'package:otraku/util/routes.dart'; 5 | import 'package:otraku/util/theming.dart'; 6 | import 'package:otraku/widget/cached_image.dart'; 7 | import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; 8 | 9 | class UserItemGrid extends StatelessWidget { 10 | const UserItemGrid(this.items); 11 | 12 | final List items; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return SliverGrid( 17 | gridDelegate: const SliverGridDelegateWithMinWidthAndExtraHeight( 18 | minWidth: 100, 19 | extraHeight: 40, 20 | ), 21 | delegate: SliverChildBuilderDelegate( 22 | (_, i) => _Tile(items[i]), 23 | childCount: items.length, 24 | ), 25 | ); 26 | } 27 | } 28 | 29 | class _Tile extends StatelessWidget { 30 | const _Tile(this.item); 31 | 32 | final UserItem item; 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | return InkWell( 37 | borderRadius: Theming.borderRadiusSmall, 38 | onTap: () => context.push(Routes.user(item.id, item.imageUrl)), 39 | child: Column( 40 | children: [ 41 | Expanded( 42 | child: Hero( 43 | tag: item.id, 44 | child: ClipRRect( 45 | borderRadius: Theming.borderRadiusSmall, 46 | child: CachedImage(item.imageUrl), 47 | ), 48 | ), 49 | ), 50 | const SizedBox(height: 5), 51 | SizedBox( 52 | height: 35, 53 | child: Text( 54 | item.name, 55 | maxLines: 2, 56 | overflow: TextOverflow.fade, 57 | style: TextTheme.of(context).bodyMedium, 58 | ), 59 | ), 60 | ], 61 | ), 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/feature/user/user_item_model.dart: -------------------------------------------------------------------------------- 1 | class UserItem { 2 | const UserItem._({ 3 | required this.id, 4 | required this.name, 5 | required this.imageUrl, 6 | }); 7 | 8 | factory UserItem(Map map) => UserItem._( 9 | id: map['id'], 10 | name: map['name'], 11 | imageUrl: map['avatar']['large'], 12 | ); 13 | 14 | final int id; 15 | final String name; 16 | final String imageUrl; 17 | } 18 | -------------------------------------------------------------------------------- /lib/feature/user/user_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:otraku/extension/string_extension.dart'; 2 | import 'package:otraku/util/markdown.dart'; 3 | import 'package:otraku/feature/statistics/statistics_model.dart'; 4 | 5 | class User { 6 | User._({ 7 | required this.id, 8 | required this.name, 9 | required this.description, 10 | required this.imageUrl, 11 | required this.bannerUrl, 12 | required this.siteUrl, 13 | required this.isFollowed, 14 | required this.isFollower, 15 | required this.isBlocked, 16 | required this.donatorTier, 17 | required this.donatorBadge, 18 | required this.modRoles, 19 | required this.animeStats, 20 | required this.mangaStats, 21 | }); 22 | 23 | factory User(Map map) { 24 | final modRoles = []; 25 | if (map['moderatorRoles'] != null) { 26 | for (String r in map['moderatorRoles']) { 27 | modRoles.add(r.noScreamingSnakeCase); 28 | } 29 | } 30 | 31 | return User._( 32 | id: map['id'], 33 | name: map['name'], 34 | description: parseMarkdown(map['about'] ?? ''), 35 | imageUrl: map['avatar']['large'], 36 | bannerUrl: map['bannerImage'], 37 | siteUrl: map['siteUrl'], 38 | isFollowed: map['isFollowing'] ?? false, 39 | isFollower: map['isFollower'] ?? false, 40 | isBlocked: map['isBlocked'] ?? false, 41 | donatorTier: map['donatorTier'] ?? 0, 42 | donatorBadge: map['donatorBadge'] ?? '', 43 | modRoles: modRoles, 44 | animeStats: Statistics(map['statistics']['anime'], true), 45 | mangaStats: Statistics(map['statistics']['manga'], false), 46 | ); 47 | } 48 | 49 | final int id; 50 | final String name; 51 | final String description; 52 | final String imageUrl; 53 | final String? bannerUrl; 54 | final String? siteUrl; 55 | bool isFollowed; 56 | final bool isFollower; 57 | final bool isBlocked; 58 | final int donatorTier; 59 | final String donatorBadge; 60 | final List modRoles; 61 | final Statistics animeStats; 62 | final Statistics mangaStats; 63 | } 64 | -------------------------------------------------------------------------------- /lib/feature/user/user_providers.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 | import 'package:otraku/extension/future_extension.dart'; 5 | import 'package:otraku/feature/user/user_model.dart'; 6 | import 'package:otraku/feature/viewer/repository_provider.dart'; 7 | import 'package:otraku/util/graphql.dart'; 8 | 9 | typedef UserTag = ({int? id, String? name}); 10 | 11 | UserTag idUserTag(int id) => (id: id, name: null); 12 | 13 | UserTag nameUserTag(String name) => (id: null, name: name); 14 | 15 | final userProvider = 16 | AsyncNotifierProvider.autoDispose.family( 17 | UserNotifier.new, 18 | ); 19 | 20 | class UserNotifier extends AutoDisposeFamilyAsyncNotifier { 21 | @override 22 | FutureOr build(UserTag arg) async { 23 | final data = await ref.read(repositoryProvider).request( 24 | GqlQuery.user, 25 | arg.id != null ? {'id': arg.id} : {'name': arg.name}, 26 | ); 27 | return User(data['User']); 28 | } 29 | 30 | Future toggleFollow(int userId) { 31 | return ref.read(repositoryProvider).request( 32 | GqlMutation.toggleFollow, 33 | {'userId': userId}, 34 | ).getErrorOrNull(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/feature/viewer/repository_model.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:http/http.dart'; 6 | 7 | class Repository { 8 | static final _url = Uri.parse('https://graphql.anilist.co'); 9 | 10 | Repository(String? accessToken) 11 | : _headers = { 12 | 'Accept': 'application/json', 13 | 'Content-type': 'application/json', 14 | if (accessToken != null) 'Authorization': 'Bearer $accessToken', 15 | }; 16 | 17 | final Map _headers; 18 | 19 | Future> request( 20 | String query, [ 21 | Map variables = const {}, 22 | ]) async { 23 | try { 24 | final response = await post( 25 | _url, 26 | body: json.encode({'query': query, 'variables': variables}), 27 | headers: _headers, 28 | ).timeout(const Duration(seconds: 30)); 29 | 30 | final Map body = json.decode(response.body); 31 | 32 | if (body.containsKey('errors')) { 33 | throw StateError( 34 | (body['errors'] as List) 35 | .map((e) => e['message'].toString()) 36 | .join(', '), 37 | ); 38 | } 39 | 40 | return body['data']; 41 | } on SocketException { 42 | throw Exception('Failed to connect'); 43 | } on TimeoutException { 44 | throw Exception('Request took too long'); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/feature/viewer/repository_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 2 | import 'package:otraku/feature/viewer/persistence_model.dart'; 3 | import 'package:otraku/feature/viewer/persistence_provider.dart'; 4 | import 'package:otraku/feature/viewer/repository_model.dart'; 5 | 6 | final repositoryProvider = NotifierProvider( 7 | RepositoryNotifier.new, 8 | ); 9 | 10 | class RepositoryNotifier extends Notifier { 11 | @override 12 | Repository build() { 13 | final accessToken = ref.watch( 14 | persistenceProvider.select((s) => s.accountGroup.account?.accessToken), 15 | ); 16 | 17 | return Repository(accessToken); 18 | } 19 | 20 | Future initAccount( 21 | String token, 22 | int secondsUntilExpiration, 23 | ) async { 24 | try { 25 | final data = await Repository(token).request( 26 | 'query Viewer {Viewer {id name avatar {large}}}', 27 | ); 28 | 29 | final id = data['Viewer']?['id']; 30 | final name = data['Viewer']?['name']; 31 | final avatarUrl = data['Viewer']?['avatar']?['large']; 32 | if (id == null || name == null || avatarUrl == null) { 33 | return null; 34 | } 35 | 36 | final expiration = DateTime.now().add( 37 | Duration(seconds: secondsUntilExpiration, days: -1), 38 | ); 39 | 40 | return Account( 41 | id: id, 42 | name: name, 43 | avatarUrl: avatarUrl, 44 | expiration: expiration, 45 | accessToken: token, 46 | ); 47 | } catch (_) { 48 | return null; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/util/debounce.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | /// After [_delay] time has passed, since the last [run] call, call [callback]. 4 | /// E.g. do a search query after the user stops typing. 5 | class Debounce { 6 | static const _delay = Duration(milliseconds: 600); 7 | 8 | Timer? _timer; 9 | 10 | void cancel() => _timer?.cancel(); 11 | 12 | void run(void Function() callback) { 13 | _timer?.cancel(); 14 | _timer = Timer(_delay, callback); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/util/paged.dart: -------------------------------------------------------------------------------- 1 | /// Collection for pagination. 2 | class Paged { 3 | const Paged({ 4 | this.items = const [], 5 | this.hasNext = true, 6 | this.next = 1, 7 | }); 8 | 9 | final List items; 10 | 11 | /// If there's another page to load. 12 | final bool hasNext; 13 | 14 | /// The index of the next page to be loaded. 15 | final int next; 16 | 17 | /// Recreate with another page loaded. 18 | Paged withNext(List items, bool hasNext) => Paged( 19 | items: [...this.items, ...items], 20 | hasNext: hasNext, 21 | next: next + 1, 22 | ); 23 | } 24 | 25 | class PagedWithTotal extends Paged { 26 | const PagedWithTotal({ 27 | super.items, 28 | super.hasNext, 29 | super.next, 30 | this.total = 0, 31 | }); 32 | 33 | /// Count of all items, even the ones that aren't yet loaded. 34 | final int total; 35 | 36 | @override 37 | PagedWithTotal withNext(List items, bool hasNext, [int? total]) => 38 | PagedWithTotal( 39 | items: [...this.items, ...items], 40 | hasNext: hasNext, 41 | next: next + 1, 42 | total: total ?? this.total, 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /lib/util/paged_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | /// A [ScrollController] that can perform and action when 4 | /// the bottom of the page is reached. Used for pagination. 5 | class PagedController extends ScrollController { 6 | PagedController({required this.loadMore}) { 7 | addListener(_listener); 8 | } 9 | 10 | /// The callback to call, when the end of the page is reached. 11 | /// While it can be replaced, do so only if absolutely needed. 12 | void Function() loadMore; 13 | 14 | /// Keeps track of the last [position.maxScrollExtent]. 15 | /// Used to ensure that when the end of the page is reached, 16 | /// only one call to [loadMore] is performed, at least until 17 | /// the bottom of the newly expanded page is reached. 18 | double _lastMaxExtent = 0; 19 | 20 | /// When the user reaches the bottom, try loading more data. 21 | void _listener() { 22 | if (!hasClients) return; 23 | if (positions.last.pixels < positions.last.maxScrollExtent - 100) return; 24 | if (_lastMaxExtent == positions.last.maxScrollExtent) return; 25 | 26 | _lastMaxExtent = positions.last.maxScrollExtent; 27 | loadMore(); 28 | } 29 | 30 | /// When a scrollable is detached, [_lastMaxExtent] needs to be reset, so 31 | /// that it would work properly, if the scrollable gets attached again. 32 | @override 33 | void detach(ScrollPosition position) { 34 | _lastMaxExtent = 0; 35 | super.detach(position); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/util/tile_modelable.dart: -------------------------------------------------------------------------------- 1 | /// A lot of models have commonly accessed elements 2 | /// that can be unified and used in agnostic views. 3 | abstract class TileModelable { 4 | int get tileId; 5 | String get tileTitle; 6 | String? get tileSubtitle; 7 | String get tileImageUrl; 8 | } 9 | -------------------------------------------------------------------------------- /lib/widget/cached_image.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_cache_manager/flutter_cache_manager.dart'; 4 | import 'package:otraku/extension/snack_bar_extension.dart'; 5 | 6 | /// A custom cache manager is needed to define exact image cap and stale period. 7 | final _cacheManager = CacheManager( 8 | Config( 9 | 'imageCache', 10 | maxNrOfCacheObjects: 1000, 11 | stalePeriod: const Duration(days: 10), 12 | ), 13 | ); 14 | 15 | /// Erases image cache. 16 | void clearImageCache() => _cacheManager.emptyCache(); 17 | 18 | /// A [CachedNetworkImage] wrapper that simplifies the interface 19 | /// and uses the custom cache manager, without exposing it. 20 | class CachedImage extends StatelessWidget { 21 | const CachedImage( 22 | this.imageUrl, { 23 | this.fit = BoxFit.cover, 24 | this.width = double.infinity, 25 | this.height = double.infinity, 26 | }); 27 | 28 | final String imageUrl; 29 | final BoxFit fit; 30 | final double? width; 31 | final double? height; 32 | 33 | @override 34 | Widget build(BuildContext context) { 35 | return CachedNetworkImage( 36 | imageUrl: imageUrl, 37 | fit: fit, 38 | width: width, 39 | height: height, 40 | cacheManager: _cacheManager, 41 | fadeInDuration: const Duration(milliseconds: 300), 42 | fadeOutDuration: const Duration(milliseconds: 300), 43 | errorWidget: (context, _, __) => IconButton( 44 | tooltip: 'Error', 45 | icon: const Icon(Icons.close_outlined), 46 | onPressed: () => 47 | SnackBarExtension.show(context, 'Failed to load image'), 48 | ), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/widget/grid/mono_relation_grid.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/util/theming.dart'; 3 | import 'package:otraku/util/tile_modelable.dart'; 4 | import 'package:otraku/widget/cached_image.dart'; 5 | import 'package:otraku/widget/grid/sliver_grid_delegates.dart'; 6 | 7 | class MonoRelationGrid extends StatelessWidget { 8 | const MonoRelationGrid({required this.items, required this.onTap}); 9 | 10 | final List items; 11 | final void Function(TileModelable item) onTap; 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | if (items.isEmpty) return const SliverToBoxAdapter(); 16 | 17 | return SliverGrid( 18 | gridDelegate: const SliverGridDelegateWithMinWidthAndFixedHeight( 19 | minWidth: 240, 20 | height: 115, 21 | ), 22 | delegate: SliverChildBuilderDelegate( 23 | childCount: items.length, 24 | (context, i) => _Tile(item: items[i], onTap: onTap), 25 | ), 26 | ); 27 | } 28 | } 29 | 30 | class _Tile extends StatelessWidget { 31 | const _Tile({required this.item, required this.onTap}); 32 | 33 | final TileModelable item; 34 | final void Function(TileModelable item) onTap; 35 | 36 | @override 37 | Widget build(BuildContext context) { 38 | return Card( 39 | child: InkWell( 40 | borderRadius: Theming.borderRadiusSmall, 41 | onTap: () => onTap(item), 42 | child: Row( 43 | children: [ 44 | ClipRRect( 45 | borderRadius: Theming.borderRadiusSmall, 46 | child: CachedImage(item.tileImageUrl, width: 80), 47 | ), 48 | Expanded( 49 | child: Padding( 50 | padding: Theming.paddingAll, 51 | child: Column( 52 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 53 | crossAxisAlignment: CrossAxisAlignment.start, 54 | children: [ 55 | Flexible( 56 | child: Text( 57 | item.tileTitle, 58 | overflow: TextOverflow.fade, 59 | ), 60 | ), 61 | if (item.tileSubtitle != null) 62 | Text( 63 | item.tileSubtitle!, 64 | maxLines: 4, 65 | overflow: TextOverflow.fade, 66 | style: TextTheme.of(context).labelSmall, 67 | ), 68 | ], 69 | ), 70 | ), 71 | ), 72 | ], 73 | ), 74 | ), 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/widget/input/date_field.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:ionicons/ionicons.dart'; 3 | import 'package:otraku/extension/date_time_extension.dart'; 4 | import 'package:otraku/util/theming.dart'; 5 | 6 | class DateField extends StatefulWidget { 7 | const DateField({ 8 | required this.label, 9 | required this.value, 10 | required this.onChanged, 11 | }); 12 | 13 | final String label; 14 | final DateTime? value; 15 | final Function(DateTime?) onChanged; 16 | 17 | @override 18 | State createState() => _DateFieldState(); 19 | } 20 | 21 | class _DateFieldState extends State { 22 | late DateTime? _value = widget.value; 23 | late final _ctrl = TextEditingController(text: _value?.formattedDate ?? ''); 24 | 25 | @override 26 | void didUpdateWidget(covariant DateField oldWidget) { 27 | super.didUpdateWidget(oldWidget); 28 | _value = widget.value; 29 | final text = _value?.formattedDate ?? ''; 30 | if (_ctrl.text != text) _ctrl.text = text; 31 | } 32 | 33 | @override 34 | void dispose() { 35 | _ctrl.dispose(); 36 | super.dispose(); 37 | } 38 | 39 | @override 40 | Widget build(BuildContext context) { 41 | return TextField( 42 | readOnly: true, 43 | controller: _ctrl, 44 | textAlign: TextAlign.center, 45 | onTap: () => showDatePicker( 46 | context: context, 47 | initialDate: _value ?? DateTime.now(), 48 | firstDate: DateTime(1920), 49 | lastDate: DateTime.now(), 50 | errorInvalidText: 'Enter date in valid range', 51 | errorFormatText: 'Enter valid date', 52 | confirmText: 'Done', 53 | cancelText: 'Cancel', 54 | fieldLabelText: '', 55 | helpText: '', 56 | ).then((pickedDate) { 57 | if (pickedDate == null) return; 58 | 59 | _value = pickedDate; 60 | _ctrl.text = _value?.formattedDate ?? ''; 61 | widget.onChanged(pickedDate); 62 | }), 63 | decoration: InputDecoration( 64 | labelText: widget.label, 65 | border: const OutlineInputBorder(), 66 | suffixIcon: Semantics( 67 | button: true, 68 | child: Material( 69 | color: Colors.transparent, 70 | child: InkResponse( 71 | radius: Theming.radiusSmall.x, 72 | child: const Tooltip( 73 | message: 'Clear', 74 | child: Icon(Ionicons.close_outline), 75 | ), 76 | onTap: () { 77 | _ctrl.text = ''; 78 | widget.onChanged(null); 79 | }, 80 | ), 81 | ), 82 | ), 83 | ), 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/widget/input/note_label.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:ionicons/ionicons.dart'; 3 | import 'package:otraku/util/theming.dart'; 4 | import 'package:otraku/widget/dialogs.dart'; 5 | 6 | class NotesLabel extends StatelessWidget { 7 | const NotesLabel(this.notes); 8 | 9 | final String notes; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | if (notes.isEmpty) return const SizedBox(); 14 | 15 | return SizedBox( 16 | height: 35, 17 | child: Tooltip( 18 | message: 'Comment', 19 | child: InkResponse( 20 | radius: Theming.radiusSmall.x, 21 | child: const Icon(Ionicons.chatbox, size: Theming.iconSmall), 22 | onTap: () => showDialog( 23 | context: context, 24 | builder: (context) => TextDialog( 25 | title: 'Comment', 26 | text: notes, 27 | ), 28 | ), 29 | ), 30 | ), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/widget/input/pill_selector.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/util/theming.dart'; 3 | 4 | class PillSelector extends StatelessWidget { 5 | const PillSelector({ 6 | required this.selected, 7 | required this.items, 8 | required this.onTap, 9 | this.maxWidth = double.infinity, 10 | this.shrinkWrap = false, 11 | this.scrollCtrl, 12 | }); 13 | 14 | final int? selected; 15 | final List items; 16 | final void Function(int) onTap; 17 | final double maxWidth; 18 | final bool shrinkWrap; 19 | final ScrollController? scrollCtrl; 20 | 21 | /// Approximation for a needed base height to display its contents. 22 | /// Can be used to calculate the initial size of sheets. 23 | static double expectedMinHeight(int itemCount) => 24 | (Theming.minTapTarget + Theming.offset / 2) * itemCount + 25 | Theming.offset * 2; 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | return ConstrainedBox( 30 | constraints: BoxConstraints(maxWidth: maxWidth), 31 | child: ListView.separated( 32 | controller: scrollCtrl, 33 | shrinkWrap: shrinkWrap, 34 | padding: MediaQuery.paddingOf(context).add(Theming.paddingAll), 35 | itemCount: items.length, 36 | separatorBuilder: (context, _) => const SizedBox( 37 | height: Theming.offset / 2, 38 | ), 39 | itemBuilder: (context, i) => Material( 40 | shape: const StadiumBorder(), 41 | color: 42 | i == selected ? ColorScheme.of(context).secondaryContainer : null, 43 | child: InkWell( 44 | customBorder: const StadiumBorder(), 45 | onTap: () => onTap(i), 46 | child: ConstrainedBox( 47 | constraints: const BoxConstraints( 48 | minHeight: Theming.minTapTarget, 49 | ), 50 | child: Padding( 51 | padding: const EdgeInsets.symmetric( 52 | horizontal: Theming.offset * 1.5, 53 | vertical: Theming.offset * 0.5, 54 | ), 55 | child: Align( 56 | alignment: Alignment.centerLeft, 57 | child: items[i], 58 | ), 59 | ), 60 | ), 61 | ), 62 | ), 63 | ), 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/widget/input/score_label.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/feature/media/media_models.dart'; 3 | import 'package:otraku/util/theming.dart'; 4 | 5 | class ScoreLabel extends StatelessWidget { 6 | const ScoreLabel(this.score, this.scoreFormat); 7 | 8 | final double score; 9 | final ScoreFormat scoreFormat; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | if (score == 0) return const SizedBox(); 14 | 15 | Widget content; 16 | switch (scoreFormat) { 17 | case ScoreFormat.point3: 18 | if (score == 3) { 19 | content = const Icon( 20 | Icons.sentiment_very_satisfied, 21 | size: Theming.iconSmall, 22 | ); 23 | } else if (score == 2) { 24 | content = const Icon( 25 | Icons.sentiment_neutral, 26 | size: Theming.iconSmall, 27 | ); 28 | } else { 29 | content = const Icon( 30 | Icons.sentiment_very_dissatisfied, 31 | size: Theming.iconSmall, 32 | ); 33 | } 34 | case ScoreFormat.point5: 35 | content = Row( 36 | mainAxisSize: MainAxisSize.min, 37 | children: [ 38 | Text( 39 | score.toStringAsFixed(0), 40 | style: TextTheme.of(context).labelSmall, 41 | ), 42 | const SizedBox(width: 3), 43 | const Icon(Icons.star_rounded, size: Theming.iconSmall), 44 | ], 45 | ); 46 | case ScoreFormat.point10Decimal: 47 | content = Row( 48 | mainAxisSize: MainAxisSize.min, 49 | children: [ 50 | const Icon(Icons.star_half_rounded, size: Theming.iconSmall), 51 | const SizedBox(width: 3), 52 | Text( 53 | score.toStringAsFixed(1), 54 | style: TextTheme.of(context).labelSmall, 55 | ), 56 | ], 57 | ); 58 | default: 59 | content = Row( 60 | mainAxisSize: MainAxisSize.min, 61 | children: [ 62 | const Icon(Icons.star_half_rounded, size: Theming.iconSmall), 63 | const SizedBox(width: 3), 64 | Text( 65 | score.toStringAsFixed(0), 66 | style: TextTheme.of(context).labelSmall, 67 | ), 68 | ], 69 | ); 70 | } 71 | 72 | return Tooltip(message: 'Score', child: content); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/widget/input/search_field.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/util/theming.dart'; 3 | import 'package:otraku/util/debounce.dart'; 4 | 5 | class SearchField extends StatefulWidget { 6 | const SearchField({ 7 | required this.value, 8 | required this.hint, 9 | required this.onChanged, 10 | this.focusNode, 11 | this.debounce, 12 | }); 13 | 14 | final String value; 15 | final String hint; 16 | final void Function(String) onChanged; 17 | final FocusNode? focusNode; 18 | final Debounce? debounce; 19 | 20 | @override 21 | State createState() => _SearchFieldState(); 22 | } 23 | 24 | class _SearchFieldState extends State { 25 | late final _ctrl = TextEditingController(text: widget.value); 26 | 27 | @override 28 | void didUpdateWidget(covariant SearchField oldWidget) { 29 | super.didUpdateWidget(oldWidget); 30 | if (_ctrl.text != widget.value) _ctrl.text = widget.value; 31 | } 32 | 33 | @override 34 | void dispose() { 35 | _ctrl.dispose(); 36 | super.dispose(); 37 | } 38 | 39 | @override 40 | Widget build(BuildContext context) { 41 | return Semantics( 42 | label: 'Search', 43 | child: TextField( 44 | controller: _ctrl, 45 | focusNode: widget.focusNode, 46 | style: TextTheme.of(context).bodyMedium, 47 | onChanged: (val) { 48 | if (val.isEmpty) { 49 | widget.debounce?.cancel(); 50 | widget.onChanged(''); 51 | return; 52 | } 53 | 54 | if (widget.debounce != null) { 55 | widget.debounce!.run(() => widget.onChanged(val)); 56 | } else { 57 | widget.onChanged(val); 58 | } 59 | }, 60 | decoration: InputDecoration( 61 | isDense: false, 62 | hintText: widget.hint, 63 | filled: true, 64 | fillColor: ColorScheme.of(context).surfaceContainerHighest, 65 | contentPadding: const EdgeInsets.only(left: 15), 66 | constraints: const BoxConstraints(minHeight: 35, maxHeight: 40), 67 | suffixIcon: _ctrl.text.isNotEmpty 68 | ? IconButton( 69 | tooltip: 'Clear', 70 | iconSize: Theming.iconSmall, 71 | icon: const Icon(Icons.close_rounded), 72 | color: ColorScheme.of(context).onSurface, 73 | padding: const EdgeInsets.all(0), 74 | onPressed: () { 75 | _ctrl.clear(); 76 | widget.debounce?.cancel(); 77 | widget.onChanged(''); 78 | }, 79 | ) 80 | : null, 81 | ), 82 | ), 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/widget/input/stateful_tiles.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// A wrapper around [SwitchListTile.adaptive], which handles state. 4 | class StatefulSwitchListTile extends StatefulWidget { 5 | const StatefulSwitchListTile({ 6 | required this.title, 7 | required this.value, 8 | required this.onChanged, 9 | this.subtitle, 10 | }); 11 | 12 | final Widget title; 13 | final Widget? subtitle; 14 | final bool value; 15 | final void Function(bool) onChanged; 16 | 17 | @override 18 | State createState() => _StatefulSwitchListTileState(); 19 | } 20 | 21 | class _StatefulSwitchListTileState extends State { 22 | late bool _value = widget.value; 23 | 24 | @override 25 | void didUpdateWidget(covariant StatefulSwitchListTile oldWidget) { 26 | super.didUpdateWidget(oldWidget); 27 | _value = widget.value; 28 | } 29 | 30 | @override 31 | Widget build(BuildContext context) { 32 | return SwitchListTile.adaptive( 33 | // The active color needs to be overriden, because 34 | // the cupertino selected state won't pick it up otherwise. 35 | activeTrackColor: ColorScheme.of(context).primary, 36 | title: widget.title, 37 | subtitle: widget.subtitle, 38 | value: _value, 39 | onChanged: (v) { 40 | setState(() => _value = v); 41 | widget.onChanged(v); 42 | }, 43 | ); 44 | } 45 | } 46 | 47 | /// A wrapper around [CheckboxListTile.adaptive], which handles state. 48 | class StatefulCheckboxListTile extends StatefulWidget { 49 | const StatefulCheckboxListTile({ 50 | required this.value, 51 | required this.onChanged, 52 | this.tristate = false, 53 | this.title, 54 | }); 55 | 56 | final bool? value; 57 | final void Function(bool?) onChanged; 58 | final Widget? title; 59 | final bool tristate; 60 | 61 | @override 62 | State createState() => 63 | _StatefulCheckboxListTileState(); 64 | } 65 | 66 | class _StatefulCheckboxListTileState extends State { 67 | late bool? _value = widget.value; 68 | 69 | @override 70 | void didUpdateWidget(covariant StatefulCheckboxListTile oldWidget) { 71 | super.didUpdateWidget(oldWidget); 72 | _value = widget.value; 73 | } 74 | 75 | @override 76 | Widget build(BuildContext context) { 77 | return CheckboxListTile.adaptive( 78 | // The active color needs to be overriden, because 79 | // the cupertino selected state won't pick it up otherwise. 80 | activeColor: ColorScheme.of(context).primary, 81 | title: widget.title, 82 | tristate: widget.tristate, 83 | value: _value, 84 | onChanged: (v) { 85 | setState(() => _value = v); 86 | widget.onChanged(v); 87 | }, 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /lib/widget/input/year_range_picker.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/widgets.dart'; 3 | import 'package:otraku/widget/input/number_field.dart'; 4 | 5 | const _minYear = 1917; 6 | 7 | class YearRangePicker extends StatefulWidget { 8 | const YearRangePicker({ 9 | required this.title, 10 | required this.from, 11 | required this.to, 12 | required this.onChanged, 13 | }); 14 | 15 | final String title; 16 | final int? from; 17 | final int? to; 18 | final void Function(int?, int?) onChanged; 19 | 20 | @override 21 | State createState() => _YearRangePickerState(); 22 | } 23 | 24 | class _YearRangePickerState extends State { 25 | late int _maxYear; 26 | late int _from; 27 | late int _to; 28 | 29 | @override 30 | void initState() { 31 | super.initState(); 32 | _init(); 33 | } 34 | 35 | @override 36 | void didUpdateWidget(covariant YearRangePicker oldWidget) { 37 | super.didUpdateWidget(oldWidget); 38 | _init(); 39 | } 40 | 41 | void _init() { 42 | _maxYear = DateTime.now().year + 1; 43 | _from = widget.from ?? _minYear; 44 | _to = widget.to ?? _maxYear; 45 | if (_from < _minYear) _from = _minYear; 46 | if (_to > _maxYear) _to = _maxYear; 47 | if (_from > _to) _from = _to; 48 | } 49 | 50 | @override 51 | Widget build(BuildContext context) { 52 | return Row( 53 | children: [ 54 | Expanded( 55 | child: NumberField( 56 | label: 'Release Start', 57 | value: _from, 58 | minValue: _minYear, 59 | maxValue: _maxYear, 60 | onChanged: (from) { 61 | setState(() { 62 | _from = from; 63 | if (_to < _from) _to = _from; 64 | }); 65 | 66 | _from > _minYear || _to < _maxYear 67 | ? widget.onChanged(_from, _to) 68 | : widget.onChanged(null, null); 69 | }, 70 | ), 71 | ), 72 | const SizedBox(width: 10), 73 | Expanded( 74 | child: NumberField( 75 | label: 'Release End', 76 | value: _to, 77 | minValue: _minYear, 78 | maxValue: _maxYear, 79 | onChanged: (to) { 80 | setState(() { 81 | _to = to; 82 | if (_from > _to) _from = _to; 83 | }); 84 | 85 | _from > _minYear || _to < _maxYear 86 | ? widget.onChanged(_from, _to) 87 | : widget.onChanged(null, null); 88 | }, 89 | ), 90 | ), 91 | ], 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/widget/layout/constrained_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:otraku/util/theming.dart'; 3 | 4 | /// Horizontally constrains [child] in the center. 5 | class ConstrainedView extends StatelessWidget { 6 | const ConstrainedView({required this.child, this.padded = true}); 7 | 8 | final Widget child; 9 | final bool padded; 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return Center( 14 | child: Padding( 15 | padding: padded 16 | ? const EdgeInsets.symmetric(horizontal: Theming.offset) 17 | : EdgeInsets.zero, 18 | child: ConstrainedBox( 19 | constraints: const BoxConstraints( 20 | maxWidth: Theming.windowWidthMedium, 21 | ), 22 | child: child, 23 | ), 24 | ), 25 | ); 26 | } 27 | } 28 | 29 | /// An alternative to [ConstrainedView] for Sliver views. 30 | class SliverConstrainedView extends StatelessWidget { 31 | const SliverConstrainedView({required this.sliver}); 32 | 33 | final Widget sliver; 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return SliverLayoutBuilder( 38 | builder: (context, constraints) { 39 | final side = 40 | (constraints.crossAxisExtent - Theming.windowWidthMedium) / 2; 41 | 42 | return SliverPadding( 43 | padding: EdgeInsets.symmetric( 44 | horizontal: side < Theming.offset ? Theming.offset : side, 45 | ), 46 | sliver: sliver, 47 | ); 48 | }, 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/widget/layout/hiding_floating_action_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// Hides/Shows [child] on scroll. 4 | class HidingFloatingActionButton extends StatefulWidget { 5 | const HidingFloatingActionButton({ 6 | required super.key, 7 | required this.child, 8 | required this.scrollCtrl, 9 | }); 10 | 11 | final Widget child; 12 | final ScrollController scrollCtrl; 13 | 14 | @override 15 | State createState() => 16 | _HidingFloatingActionButtonState(); 17 | } 18 | 19 | class _HidingFloatingActionButtonState extends State 20 | with SingleTickerProviderStateMixin { 21 | late final AnimationController _animationCtrl; 22 | late final Animation _slideAnimation; 23 | late final Animation _fadeAnimation; 24 | 25 | var _visible = true; 26 | var _lastOffset = 0.0; 27 | 28 | void _visibility() { 29 | final pos = widget.scrollCtrl.positions.last; 30 | final dif = pos.pixels - _lastOffset; 31 | 32 | // If the position has moved enough from the last 33 | // spot or is out of bounds, hide/show the actions. 34 | if (dif > 15 || pos.pixels > pos.maxScrollExtent) { 35 | _lastOffset = pos.pixels; 36 | _animationCtrl.reverse().then((_) => setState(() => _visible = false)); 37 | } else if (dif < -15 || pos.pixels < pos.minScrollExtent) { 38 | _lastOffset = pos.pixels; 39 | setState(() => _visible = true); 40 | _animationCtrl.forward(); 41 | } 42 | } 43 | 44 | @override 45 | void initState() { 46 | super.initState(); 47 | widget.scrollCtrl.addListener(_visibility); 48 | 49 | _animationCtrl = AnimationController( 50 | duration: const Duration(milliseconds: 100), 51 | vsync: this, 52 | value: 1, 53 | ); 54 | _slideAnimation = Tween( 55 | begin: const Offset(0, 0.2), 56 | end: Offset.zero, 57 | ).animate(_animationCtrl); 58 | _fadeAnimation = Tween(begin: 0.3, end: 1.0).animate(_animationCtrl); 59 | } 60 | 61 | @override 62 | void dispose() { 63 | widget.scrollCtrl.removeListener(_visibility); 64 | _animationCtrl.dispose(); 65 | super.dispose(); 66 | } 67 | 68 | @override 69 | void didUpdateWidget(covariant HidingFloatingActionButton oldWidget) { 70 | super.didUpdateWidget(oldWidget); 71 | 72 | if (widget.scrollCtrl != oldWidget.scrollCtrl) { 73 | oldWidget.scrollCtrl.removeListener(_visibility); 74 | widget.scrollCtrl.addListener(_visibility); 75 | } 76 | } 77 | 78 | @override 79 | Widget build(BuildContext context) { 80 | if (!_visible) return const SizedBox(); 81 | 82 | return SlideTransition( 83 | position: _slideAnimation, 84 | child: FadeTransition( 85 | opacity: _fadeAnimation, 86 | child: widget.child, 87 | ), 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /lib/widget/layout/scroll_physics.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class FastTabBarViewScrollPhysics extends ScrollPhysics { 4 | const FastTabBarViewScrollPhysics({super.parent}); 5 | 6 | @override 7 | FastTabBarViewScrollPhysics applyTo(ScrollPhysics? ancestor) { 8 | return FastTabBarViewScrollPhysics(parent: buildParent(ancestor)); 9 | } 10 | 11 | @override 12 | SpringDescription get spring => const SpringDescription( 13 | mass: 50, 14 | stiffness: 100, 15 | damping: 0.8, 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /lib/widget/loaders.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:otraku/util/theming.dart'; 4 | import 'package:otraku/widget/shimmer.dart'; 5 | 6 | class Loader extends StatelessWidget { 7 | const Loader(); 8 | 9 | @override 10 | Widget build(BuildContext context) => Shimmer( 11 | ShimmerItem( 12 | Container( 13 | width: 60, 14 | height: 15, 15 | decoration: BoxDecoration( 16 | borderRadius: Theming.borderRadiusSmall, 17 | color: ColorScheme.of(context).surfaceContainerHighest, 18 | ), 19 | ), 20 | ), 21 | ); 22 | } 23 | 24 | class SliverRefreshControl extends StatelessWidget { 25 | const SliverRefreshControl({required this.onRefresh}); 26 | 27 | final void Function() onRefresh; 28 | 29 | @override 30 | Widget build(BuildContext context) { 31 | return SliverPadding( 32 | padding: EdgeInsets.only( 33 | top: MediaQuery.paddingOf(context).top + Theming.offset, 34 | ), 35 | sliver: CupertinoSliverRefreshControl( 36 | refreshIndicatorExtent: 15, 37 | refreshTriggerPullDistance: 160, 38 | onRefresh: () { 39 | onRefresh(); 40 | return Future.value(); 41 | }, 42 | builder: ( 43 | _, 44 | refreshState, 45 | pulledExtent, 46 | refreshTriggerPullDistance, 47 | refreshIndicatorExtent, 48 | ) { 49 | double visibility = 0; 50 | if (pulledExtent > refreshIndicatorExtent) { 51 | pulledExtent -= refreshIndicatorExtent; 52 | refreshTriggerPullDistance -= refreshIndicatorExtent; 53 | visibility = pulledExtent / refreshTriggerPullDistance; 54 | if (visibility > 1) visibility = 1; 55 | } 56 | 57 | return switch (refreshState) { 58 | RefreshIndicatorMode.inactive => const SizedBox(), 59 | _ => Opacity( 60 | opacity: visibility, 61 | child: const Center(child: Loader()), 62 | ), 63 | }; 64 | }, 65 | ), 66 | ); 67 | } 68 | } 69 | 70 | class SliverFooter extends StatelessWidget { 71 | const SliverFooter({this.loading = false}); 72 | 73 | final bool loading; 74 | 75 | @override 76 | Widget build(BuildContext context) { 77 | return SliverToBoxAdapter( 78 | child: Center( 79 | child: Padding( 80 | padding: EdgeInsets.only( 81 | top: Theming.offset, 82 | bottom: MediaQuery.paddingOf(context).bottom + Theming.offset, 83 | ), 84 | child: loading ? const Loader() : null, 85 | ), 86 | ), 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/widget/shadowed_overflow_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/util/theming.dart'; 3 | 4 | /// A horizontal list with inner shadow 5 | /// on the left and right that indicates overflow. 6 | class ShadowedOverflowList extends StatelessWidget { 7 | const ShadowedOverflowList({ 8 | required this.itemCount, 9 | required this.itemBuilder, 10 | this.shrinkWrap = false, 11 | this.itemExtent, 12 | }); 13 | 14 | final int itemCount; 15 | final Widget Function(BuildContext context, int i) itemBuilder; 16 | final double? itemExtent; 17 | final bool shrinkWrap; 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return Stack( 22 | children: [ 23 | ListView.builder( 24 | scrollDirection: Axis.horizontal, 25 | padding: const EdgeInsets.only( 26 | left: Theming.offset, 27 | right: Theming.offset / 2, 28 | bottom: 2, 29 | ), 30 | itemExtent: itemExtent, 31 | itemCount: itemCount, 32 | shrinkWrap: shrinkWrap, 33 | itemBuilder: (context, i) => Padding( 34 | padding: const EdgeInsets.only(right: Theming.offset / 2), 35 | child: itemBuilder(context, i), 36 | ), 37 | ), 38 | Positioned( 39 | top: 0, 40 | left: 0, 41 | bottom: 0, 42 | child: SizedBox( 43 | width: Theming.offset, 44 | child: DecoratedBox( 45 | decoration: BoxDecoration( 46 | gradient: LinearGradient( 47 | begin: Alignment.centerLeft, 48 | end: Alignment.centerRight, 49 | colors: [ 50 | ColorScheme.of(context).surface, 51 | ColorScheme.of(context).surface.withValues(alpha: 0), 52 | ], 53 | ), 54 | ), 55 | ), 56 | ), 57 | ), 58 | Positioned( 59 | top: 0, 60 | right: 0, 61 | bottom: 0, 62 | child: SizedBox( 63 | width: Theming.offset, 64 | child: DecoratedBox( 65 | decoration: BoxDecoration( 66 | gradient: LinearGradient( 67 | begin: Alignment.centerRight, 68 | end: Alignment.centerLeft, 69 | colors: [ 70 | ColorScheme.of(context).surface, 71 | ColorScheme.of(context).surface.withValues(alpha: 0), 72 | ], 73 | ), 74 | ), 75 | ), 76 | ), 77 | ), 78 | ], 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/widget/swipe_switcher.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// Rotates between [children] when swiped. 4 | class SwipeSwitcher extends StatelessWidget { 5 | const SwipeSwitcher({ 6 | required this.index, 7 | required this.children, 8 | required this.onChanged, 9 | }); 10 | 11 | final int index; 12 | final List children; 13 | final void Function(int) onChanged; 14 | 15 | static const _triggerOffset = 20.0; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | var swipeStart = 0.0; 20 | 21 | return GestureDetector( 22 | behavior: HitTestBehavior.translucent, 23 | onHorizontalDragStart: (start) => swipeStart = start.globalPosition.dx, 24 | onHorizontalDragUpdate: (update) { 25 | if (swipeStart == 0) return; 26 | final dif = swipeStart - update.globalPosition.dx; 27 | 28 | if (dif > _triggerOffset) { 29 | onChanged(index < children.length - 1 ? index + 1 : 0); 30 | swipeStart = 0; 31 | } else if (dif < -_triggerOffset) { 32 | onChanged(index > 0 ? index - 1 : children.length - 1); 33 | swipeStart = 0; 34 | } 35 | }, 36 | child: children[index], 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/widget/text_rail.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// Lists text details in a fancy way, marking 4 | /// the ones that come with a [true] value. 5 | class TextRail extends StatelessWidget { 6 | const TextRail(this.items, {this.style, this.maxLines}); 7 | 8 | final Map items; 9 | final TextStyle? style; 10 | final int? maxLines; 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | if (items.isEmpty) return const SizedBox(); 15 | 16 | const spacing = TextSpan(text: ' • '); 17 | 18 | final style = this.style ?? TextTheme.of(context).labelSmall; 19 | final highlightStyle = style?.copyWith( 20 | color: ColorScheme.of(context).primary, 21 | ); 22 | 23 | return Text.rich( 24 | overflow: TextOverflow.fade, 25 | maxLines: maxLines, 26 | TextSpan( 27 | style: style, 28 | children: [ 29 | for (int i = 0; i < items.length - 1; i++) ...[ 30 | TextSpan( 31 | text: items.keys.elementAt(i), 32 | style: items.values.elementAt(i) ? highlightStyle : null, 33 | ), 34 | spacing, 35 | ], 36 | TextSpan( 37 | text: items.keys.last, 38 | style: items.values.last ? highlightStyle : null, 39 | ), 40 | ], 41 | ), 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/widget/timestamp.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:otraku/extension/date_time_extension.dart'; 3 | import 'package:otraku/extension/snack_bar_extension.dart'; 4 | import 'package:otraku/util/theming.dart'; 5 | 6 | class Timestamp extends StatelessWidget { 7 | const Timestamp( 8 | this.dateTime, 9 | this.analogClock, { 10 | this.leading = const Icon(Icons.history_rounded, size: Theming.iconSmall), 11 | }); 12 | 13 | final DateTime dateTime; 14 | final bool analogClock; 15 | final Widget leading; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | final onTap = () => SnackBarExtension.show( 20 | context, 21 | dateTime.formattedDateTimeFromSeconds(analogClock), 22 | canCopyText: true, 23 | ); 24 | 25 | return Semantics( 26 | onTap: onTap, 27 | onTapHint: 'show absolute creation time', 28 | tooltip: 'Creation Time', 29 | child: GestureDetector( 30 | onTap: onTap, 31 | child: Row( 32 | mainAxisSize: MainAxisSize.min, 33 | children: [ 34 | leading, 35 | const SizedBox(width: 5), 36 | Text( 37 | _relativeTime(), 38 | style: TextTheme.of(context).labelSmall, 39 | ) 40 | ], 41 | ), 42 | ), 43 | ); 44 | } 45 | 46 | String _relativeTime() { 47 | final diff = DateTime.now().difference(dateTime); 48 | 49 | final seconds = diff.inSeconds; 50 | if (seconds < 61) { 51 | if (seconds > 4) return '$seconds seconds ago'; 52 | 53 | return 'just now'; 54 | } 55 | 56 | final minutes = diff.inMinutes; 57 | if (minutes < 61) { 58 | if (minutes > 1) return '$minutes minutes ago'; 59 | 60 | return 'last minute'; 61 | } 62 | 63 | final hours = diff.inHours; 64 | if (hours < 25) { 65 | if (hours > 1) return '$hours hours ago'; 66 | 67 | return 'last hour'; 68 | } 69 | 70 | final days = diff.inDays; 71 | if (days < 31) { 72 | if (days > 1) return '$days days ago'; 73 | 74 | return 'yesterday'; 75 | } 76 | 77 | final months = days ~/ 30; 78 | if (months < 13) { 79 | if (months > 1) return '$months months ago'; 80 | 81 | return 'last month'; 82 | } 83 | 84 | final years = months ~/ 12; 85 | if (years > 1) return '$years years ago'; 86 | 87 | return 'last year'; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: otraku 2 | description: An unofficial AniList app. 3 | 4 | publish_to: 'none' 5 | 6 | version: 1.8.0+84 7 | 8 | environment: 9 | sdk: '>=3.0.0 <4.0.0' 10 | 11 | dependencies: 12 | flutter: 13 | sdk: flutter 14 | 15 | # State management. 16 | flutter_riverpod: ^2.6.1 17 | 18 | # Routing. 19 | go_router: ^15.1.2 20 | 21 | # Data fetching. 22 | http: ^1.4.0 23 | 24 | # Lightweight storage. 25 | hive: ^2.2.3 26 | 27 | # Access to device storage. Used for [hive] setup. 28 | path_provider: ^2.1.5 29 | 30 | # Secure storage for the access tokens. 31 | flutter_secure_storage: ^9.2.4 32 | 33 | # Markdown to HTML parsing. 34 | markdown: ^7.3.0 35 | 36 | # Used for configuring [cached_network_image]. 37 | flutter_cache_manager: ^3.4.1 38 | 39 | # Image caching. 40 | cached_network_image: ^3.4.1 41 | 42 | # Opening links in the browser. 43 | url_launcher: ^6.3.1 44 | 45 | # Access to platform theme and easy theme interpolation. 46 | dynamic_color: ^1.7.0 47 | 48 | # Background tasks for notification fetching. 49 | # TODO We are fetching the dependency from github, because the published version doesn't work 50 | # on the newest flutter. As soon as this is fixed, switch to the published version. 51 | workmanager: 52 | git: 53 | url: https://github.com/fluttercommunity/flutter_workmanager.git 54 | path: workmanager 55 | ref: main 56 | 57 | # Sending device notifications. 58 | flutter_local_notifications: ^19.1.0 59 | 60 | # Parsing html into flutter widgets. 61 | flutter_widget_from_html_core: ^0.16.0 62 | 63 | # An addition to the material icons. 64 | ionicons: ^0.2.2 65 | 66 | dev_dependencies: 67 | flutter_test: 68 | sdk: flutter 69 | 70 | flutter_lints: ^5.0.0 71 | 72 | flutter_icons: 73 | ios: true 74 | android: true 75 | image_path: "assets/icons/ios.png" 76 | adaptive_icon_background: "#0D161E" 77 | adaptive_icon_foreground: "assets/icons/android.png" 78 | 79 | flutter: 80 | uses-material-design: true 81 | 82 | assets: 83 | - assets/icons/about.png 84 | 85 | fonts: 86 | - family: Rubik 87 | fonts: 88 | - asset: assets/fonts/Rubik-VariableFont_wght.ttf 89 | - asset: assets/fonts/Rubik-Italic-VariableFont_wght.ttf 90 | style: italic 91 | --------------------------------------------------------------------------------