.
================================================
FILE: README.md
================================================
# Otraku
An unofficial AniList app.
Google Play • IzzyOnDroid (F-Droid) • Privacy Policy
The iOS .ipa and the android .apk are bundled with each Github release.
Screenshots
Building for android
1. Run `flutter build apk --split-per-abi`
2. Grab the apk release build file with your required ABI
Building for iOS
1. Run `flutter build ios --no-codesign`
2. Copy `./build/ios/iphoneos/Runner.app` into a `Payload` directory
3. Compress `Payload` and change extension to `.ipa`
================================================
FILE: analysis_options.yaml
================================================
include: package:flutter_lints/flutter.yaml
linter:
rules:
# Often unnecessary.
use_key_in_widget_constructors: false
# For closures.
prefer_function_declarations_over_variables: false
formatter:
page_width: 100
================================================
FILE: android/.gitignore
================================================
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/keystore.jks
/keystore.properties
/local.properties
GeneratedPluginRegistrant.java
.cxx/
================================================
FILE: android/app/build.gradle.kts
================================================
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("keystore.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.otraku.app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
// Desugaring is required by flutter_local_notifications.
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "com.otraku.app"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
storeFile = file(rootDir.canonicalPath + "/" + keystoreProperties["releaseKeyStore"])
storePassword = keystoreProperties["releaseStorePassword"] as String
keyPassword = keystoreProperties["releaseKeyPassword"] as String
keyAlias = keystoreProperties["releaseKeyAlias"] as String
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
flavorDimensions += "default"
productFlavors {
create("dev") {
dimension = "default"
applicationIdSuffix = ".dev"
}
}
}
dependencies {
// Desugaring is required by flutter_local_notifications.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}
flutter {
source = "../.."
}
================================================
FILE: android/app/src/debug/AndroidManifest.xml
================================================
================================================
FILE: android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
================================================
FILE: android/app/src/dev/res/values/colors.xml
================================================
#E3F2FF
================================================
FILE: android/app/src/dev/res/values/strings.xml
================================================
Otraku
================================================
FILE: android/app/src/main/AndroidManifest.xml
================================================
================================================
FILE: android/app/src/main/kotlin/com/example/otraku/MainActivity.kt
================================================
package com.otraku.app
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}
================================================
FILE: android/app/src/main/res/drawable/launch_background.xml
================================================
-
-
================================================
FILE: android/app/src/main/res/drawable-v21/launch_background.xml
================================================
-
-
================================================
FILE: android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
================================================
FILE: android/app/src/main/res/values/colors.xml
================================================
#ffffff
================================================
FILE: android/app/src/main/res/values/strings.xml
================================================
Otraku
================================================
FILE: android/app/src/main/res/values/styles.xml
================================================
================================================
FILE: android/app/src/main/res/values-night/colors.xml
================================================
#0D161E
================================================
FILE: android/app/src/main/res/values-night/styles.xml
================================================
================================================
FILE: android/app/src/main/res/xml/backup_rules.xml
================================================
================================================
FILE: android/app/src/profile/AndroidManifest.xml
================================================
================================================
FILE: android/build.gradle.kts
================================================
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean") {
delete(rootProject.layout.buildDirectory)
}
================================================
FILE: android/gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
================================================
FILE: android/gradle.properties
================================================
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
================================================
FILE: android/settings.gradle.kts
================================================
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
================================================
FILE: fastlane/metadata/android/de/full_description.txt
================================================
Otraku möchte ein voll funktionsfähiger und anpassbarer Client für AniList sein, ohne Werbung. Die App ermöglicht das Betrachten und Bearbeiten Deiner Anime/Manga Listen, das Browses und Filtern von Medien, Interaktionen mit anderen Nutzern, und mehr!
Aktuelle Funktionen:
* Zeigen Sie Ihre Anime- und Manga-Listen an und bearbeiten Sie sie
* Erkunde Anime, Manga, Charaktere, Mitarbeiter, Studios, Benutzer und Rezensionen
* Folgende / globale Feeds anzeigen
* Gefällt mir Aktivitäten und Kommentare (Kommentieren wird noch nicht unterstützt)
* Wählen Sie verschiedene App-Themen
* Konfigurieren Sie einige AniList-Einstellungen
================================================
FILE: fastlane/metadata/android/de/short_description.txt
================================================
Inoffizieller AniList-Client für Anime- und Manga-Tracking
================================================
FILE: fastlane/metadata/android/en-US/changelogs/59.txt
================================================
- Added calendar in discover to view and filter new episode schedules
- Option for pure background in settings now not only makes dark backgrounds black, but also light backgrounds white
- Fixed lazy-loading in "Following" on the media page
- Other fixes and improvements
================================================
FILE: fastlane/metadata/android/en-US/changelogs/63.txt
================================================
- Collection searching goes through both titles and notes
- Activity replies have a "Reply" button for automatic mentions
- Tapping on markdown images opens them as a popup
- Tapping on user mentions is not handled as a link, but directly opens the user page
- Tapping on a ranking in a media's statistics page redirects to the discover tab with added filters
- Deep linking on android, if configured in settings
- List status on related media in media pages
- And other visual improvements and fixes
================================================
FILE: fastlane/metadata/android/en-US/changelogs/65.txt
================================================
- Added collection filters for public/private entries and for entries with/without notes
- Changed release year filter design
- In fields, you can long-tap the decrement/increment buttons to set the value to min/max
- Reduced minimum year in release year filter to 1917
- AniList settings are saved with a floating action button now
- Fixed collection refresh forgetting the selected list
- Fixed missing entries in collections and ignored name preferences
- Fixed settings not reflecting account switching
================================================
FILE: fastlane/metadata/android/en-US/changelogs/66.txt
================================================
- Added collection filters for public/private entries and for entries with/without notes
- Changed release year filter design
- In fields, you can long-tap the decrement/increment buttons to set the value to min/max
- Reduced minimum year in release year filter to 1917
- AniList settings are saved with a floating action button now
- Fixed collection refresh forgetting the selected list
- Fixed missing entries in collections, ignored name preferences and settings not reflecting account switching
================================================
FILE: fastlane/metadata/android/en-US/changelogs/69.txt
================================================
- AniList Markdown is supported almost fully
- AniList links in markdown text are opened within the app
- More markdown quick access buttons in the composition sheet
- Collection previews can be filtered like full collections
- User/Discover reviews can be filtered by media type
- You can long-press to copy a media description
- Redesigned media overview tab and other elements
- Fixed bugs around deep link opening
- Image popups are also cached
- Other fixes and improvements
================================================
FILE: fastlane/metadata/android/en-US/changelogs/72.txt
================================================
- If your filtered collections are empty, a button can redirect you to discover with copied filters
- Tag categories in the tag sheet are sorted alphabetically
- Separate synonym titles on media pages
- Reordered fields in the entry sheet and chapter/volume fields switch based on left-handed mode
- Added an indication on whether collection/discover filters are active
- Refreshable media/user pages
- Fixed emojis, some filter names, collection tiles
- Visual tweaks and slightly darker dark mode
================================================
FILE: fastlane/metadata/android/en-US/changelogs/73.txt
================================================
- Toggled activity/reply like buttons use the primary color
- Cleaner error messages for failed connection/requests that now appear as toasts
- Replaced "gradient" sheets for activity menus, discover type selection and the like with normal sheets (may still need polishing)
- Fixed collection sorting
- Fixed activity/reply like timeout message
- Fixed home tab switching
- Fixed user refresh retrying multiple times
================================================
FILE: fastlane/metadata/android/en-US/changelogs/77.txt
================================================
- Tablet support with better layout on wide screens
- New studio page design
- New recommendations design in the media page
- Activity/Reply like icons are different depending on whether the item is liked or not
- Toast messages were replaced by snackbars
- Overall design has been tweaked in many areas
- Fixed progress-incrementing button spamming
- Fixed language order when selecting voice actor language
- And more tweaks and fixes
================================================
FILE: fastlane/metadata/android/en-US/changelogs/80.txt
================================================
- In the filter sheets for collections and discover, you can set a custom default configuration
- Basic AniList interactions are now supported without logging in
- Easier account switching from the profile tab
- You can reorder favorites and easily unfavorite them
- Timestamps are now relative, but you can tap them for an absolute date
- When incrementing the episode count from 0 on an entry in some lists, a pop up will offer to also change the list status
================================================
FILE: fastlane/metadata/android/en-US/changelogs/82.txt
================================================
- Chips on the media page are now a grid, not a scrollable row
- Fixed the the favorites editing button appearing in others' favorites
- Fixed edge cases in entry saving/removing
- Fixed list statuses in media recommendations mixing up anime and manga
- Fixed notification timestamps taking too much space
- Shortened the snackbar timeout
================================================
FILE: fastlane/metadata/android/en-US/changelogs/83.txt
================================================
- In the collection filter sheets for both your anime and manga collection, you can explicitly set the preview collection sorting, separately from the one for the full collection. The exclusive airing sorting for anime collection preview toggle is removed from settings.
- Added a doujin filter in the discover filter sheet.
- While on the profile tab of the home screen, tapping the profile icon will scroll to top like before. But now it will also open settings, if you're already at the top.
================================================
FILE: fastlane/metadata/android/en-US/changelogs/84.txt
================================================
- Added forum page with thread filters
- Added thread pages with navigation, commenting, liking and subscribing (thread writing/editing is not yet done)
- Added a tab on media pages with related threads
- Added tabs with user's threads and comments on users' social pages
- Fixed bugs related to collections, advanced scores and home page search focusing
================================================
FILE: fastlane/metadata/android/en-US/changelogs/86.txt
================================================
- Added recommendations to discover.
- Improved the recommendations tab design in the media page.
- Replaced left-handed mode setting with a more general "button orientation" setting.
- Fixed some alignment issues in thread views.
- Fixed search bar freezing when fast-switching tabs.
Important: some people have been facing performance issues after the last update. I upgraded the app engine and I'm hoping that will resolve the regressions these people face, but I can't guarantee it.
================================================
FILE: fastlane/metadata/android/en-US/changelogs/87.txt
================================================
- Fix home page tab scrolling.
- Use new material page transition on Android.
================================================
FILE: fastlane/metadata/android/en-US/changelogs/89.txt
================================================
- Added activities to a tab in the media page, with the ability to filter them by people you follow.
- Added custom list management (reordering not supported yet).
- Improved advanced score section management.
- Activities, replies, notifications and reviews are outlined when the high contrast settings is enabled (more of this in the future).
- Image caching is disabled for markdown text (it bloats the cache).
================================================
FILE: fastlane/metadata/android/en-US/changelogs/92.txt
================================================
- Expanded collections now show and filter all lists at once, though you can still view individual lists.
- High contrast mode now affects all tiles in the UI.
- Text scaling is now unconstrained and tiles adjust size to accommodate the text.
================================================
FILE: fastlane/metadata/android/en-US/changelogs/94.txt
================================================
- Added: support for the new AniList notification types - this fixes the media and other parts of the app not loading.
- Added: "Self" option in the media activity filter.
- Improved: Bar charts in user/media statistics are not horizontal, more compact and more informative.
- Improved: Buttons on the user page are now a grid.
- Fixed: wrong status bar tint on older android versions.
- Fixed: layout issues with the text being cut off.
================================================
FILE: fastlane/metadata/android/en-US/full_description.txt
================================================
Otraku aims to support most AniList features and it already covers:
- Tracking media and managing/filtering collections
- Browsing/Filtering media/characters/staff/studios/users/reviews/recommendations
- Forum
- General/User activity feeds
- Composing messages
- Calendar for release schedules
- Customization with different themes and other options
And more!
================================================
FILE: fastlane/metadata/android/en-US/short_description.txt
================================================
An unofficial AniList client for Android and iOS
================================================
FILE: fastlane/metadata/android/en-US/title.txt
================================================
Otraku
================================================
FILE: flutter_launcher_icons-dev.yaml
================================================
flutter_icons:
ios: true
android: true
image_path: "assets/icons/ios.png"
adaptive_icon_background: "#E3F2FF"
adaptive_icon_foreground: "assets/icons/android.png"
================================================
FILE: ios/.gitignore
================================================
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
================================================
FILE: ios/Flutter/AppFrameworkInfo.plist
================================================
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleExecutable
App
CFBundleIdentifier
io.flutter.flutter.app
CFBundleInfoDictionaryVersion
6.0
CFBundleName
App
CFBundlePackageType
FMWK
CFBundleShortVersionString
1.0
CFBundleSignature
????
CFBundleVersion
1.0
MinimumOSVersion
13.0
================================================
FILE: ios/Flutter/Debug.xcconfig
================================================
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
================================================
FILE: ios/Flutter/Profile.xcconfig
================================================
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
================================================
FILE: ios/Flutter/Release.xcconfig
================================================
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
================================================
FILE: ios/Podfile
================================================
# Uncomment this line to define a global platform for your project
platform :ios, '18.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
================================================
FILE: ios/Runner/AppDelegate.swift
================================================
import UIKit
import Flutter
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
================================================
FILE: ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json
================================================
{"images":[{"size":"20x20","idiom":"iphone","filename":"AppIcon-dev-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"AppIcon-dev-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-dev-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-dev-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"AppIcon-dev-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-dev-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"AppIcon-dev-40x40@3x.png","scale":"3x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-dev-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"AppIcon-dev-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-dev-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"AppIcon-dev-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-dev-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"AppIcon-dev-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-dev-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"AppIcon-dev-40x40@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-dev-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"AppIcon-dev-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"AppIcon-dev-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"AppIcon-dev-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
================================================
FILE: ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
================================================
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
================================================
FILE: ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
================================================
{
"images" : [
{
"filename" : "splash_icon-2.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "splash_icon-1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "splash_icon.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
================================================
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
================================================
FILE: ios/Runner/Base.lproj/LaunchScreen.storyboard
================================================
================================================
FILE: ios/Runner/Base.lproj/Main.storyboard
================================================
================================================
FILE: ios/Runner/Info.plist
================================================
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
Otraku
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
$(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleInfoDictionaryVersion
6.0
CFBundleName
otraku
CFBundlePackageType
APPL
CFBundleShortVersionString
$(FLUTTER_BUILD_NAME)
CFBundleSignature
????
LSApplicationQueriesSchemes
https
CFBundleURLTypes
CFBundleTypeRole
Editor
CFBundleURLName
otraku
CFBundleURLSchemes
app
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
UIBackgroundModes
fetch
processing
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
Main
UISupportedInterfaceOrientations
UIInterfaceOrientationPortrait
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad
UIInterfaceOrientationPortrait
UIInterfaceOrientationPortraitUpsideDown
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
UIViewControllerBasedStatusBarAppearance
CADisableMinimumFrameDurationOnPhone
UIApplicationSupportsIndirectInputEvents
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
UISceneConfigurations
UIWindowSceneSessionRoleApplication
UISceneClassName
UIWindowScene
UISceneDelegateClassName
FlutterSceneDelegate
UISceneConfigurationName
flutter
UISceneStoryboardFile
Main
================================================
FILE: ios/Runner/Runner-Bridging-Header.h
================================================
#import "GeneratedPluginRegistrant.h"
================================================
FILE: ios/Runner.xcodeproj/project.pbxproj
================================================
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
86DA8A2D06E6B47DD9E398A8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D7B8A4D25C8F2FF6456A7A6F /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
2296DE72BA8BDC1D4CA61399 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
644309C1A146EDFAE2F149BD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
C5A159429C34B5D065301B18 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
D7B8A4D25C8F2FF6456A7A6F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
86DA8A2D06E6B47DD9E398A8 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
39FFA31997E18B17B73C8E11 /* Frameworks */ = {
isa = PBXGroup;
children = (
D7B8A4D25C8F2FF6456A7A6F /* Pods_Runner.framework */,
);
name = Frameworks;
sourceTree = "";
};
7B3B2B22BC5AB865ADE1F85C /* Pods */ = {
isa = PBXGroup;
children = (
C5A159429C34B5D065301B18 /* Pods-Runner.debug.xcconfig */,
2296DE72BA8BDC1D4CA61399 /* Pods-Runner.release.xcconfig */,
644309C1A146EDFAE2F149BD /* Pods-Runner.profile.xcconfig */,
);
path = Pods;
sourceTree = "";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
7B3B2B22BC5AB865ADE1F85C /* Pods */,
39FFA31997E18B17B73C8E11 /* Frameworks */,
);
sourceTree = "";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
);
name = Products;
sourceTree = "";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
97C146F11CF9000F007C117D /* Supporting Files */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "";
};
97C146F11CF9000F007C117D /* Supporting Files */ = {
isa = PBXGroup;
children = (
);
name = "Supporting Files";
sourceTree = "";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
8B4083BA08B74BDD978A39C4 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
88DF2E0B0C95DD5F1FAC1F3F /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 13.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
88DF2E0B0C95DD5F1FAC1F3F /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
8B4083BA08B74BDD978A39C4 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ZBL446JY27;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
PRODUCT_BUNDLE_IDENTIFIER = com.otraku.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ZBL446JY27;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
PRODUCT_BUNDLE_IDENTIFIER = com.otraku.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ZBL446JY27;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
PRODUCT_BUNDLE_IDENTIFIER = com.otraku.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
================================================
FILE: ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
================================================
================================================
FILE: ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
================================================
IDEDidComputeMac32BitWarning
================================================
FILE: ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
================================================
PreviewsEnabled
================================================
FILE: ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
================================================
================================================
FILE: ios/Runner.xcworkspace/contents.xcworkspacedata
================================================
================================================
FILE: ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
================================================
IDEDidComputeMac32BitWarning
================================================
FILE: ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
================================================
PreviewsEnabled
================================================
FILE: lib/extension/action_chip_extension.dart
================================================
import 'package:flutter/material.dart';
extension ActionChipExtension on ActionChip {
static final highContrast = (bool highContrast) =>
highContrast ? ActionChip.new : ActionChip.elevated;
}
================================================
FILE: lib/extension/build_context_extension.dart
================================================
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/util/theming.dart';
extension BuildContextExtension on BuildContext {
void back() => canPop() ? pop() : go(Routes.home());
double lineHeight(TextStyle style) {
final scaler = MediaQuery.textScalerOf(this);
final scaled = scaler.scale(style.fontSize ?? Theming.fontMedium) * (style.height ?? 1);
return scaled.ceilToDouble();
}
}
================================================
FILE: lib/extension/card_extension.dart
================================================
import 'package:flutter/material.dart';
extension CardExtension on Card {
static final highContrast = (bool highContrast) => highContrast ? Card.outlined : Card.new;
}
================================================
FILE: lib/extension/color_extension.dart
================================================
import 'package:flutter/widgets.dart';
extension ColorExtension on Color {
static Color? fromHexString(String src) {
try {
return Color(int.parse(src.substring(1, 7), radix: 16) + 0xFF000000);
} catch (_) {
return null;
}
}
}
================================================
FILE: lib/extension/date_time_extension.dart
================================================
extension DateTimeExtension on DateTime {
int get secondsSinceEpoch => millisecondsSinceEpoch ~/ 1000;
static DateTime fromSecondsSinceEpoch(int seconds) =>
DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
static DateTime? tryFromSecondsSinceEpoch(int? seconds) =>
seconds != null ? fromSecondsSinceEpoch(seconds) : null;
String formattedDateTimeFromSeconds(bool analogClock) =>
'${_weekdayName(weekday)}, $formattedDate, ${formattedTime(analogClock)}';
static DateTime? fromFuzzyDate(Map? map) {
if (map?['year'] == null) return null;
return DateTime(map!['year'], map['month'] ?? 1, map['day'] ?? 1);
}
static String? fuzzyDateString(Map? map) {
if (map == null || map['year'] == null) return null;
final year = map['year'];
final month = map['month'];
final day = map['day'];
return '${day != null ? '$day ' : ''}'
'${month != null ? '${monthName(month)} ' : ''}'
'$year';
}
Map get fuzzyDate => {'year': year, 'month': month, 'day': day};
String get formattedWithWeekDay => '$formattedDate - ${_weekdayName(weekday)}';
String get formattedDate => '$day ${monthName(month)} $year';
String formattedTime(bool analogClock) {
if (analogClock) {
final (overflows, realHour) = hour > 12 ? (true, hour - 12) : (false, hour);
return '${realHour < 10 ? 0 : ''}$realHour'
':${minute < 10 ? 0 : ''}$minute '
'${overflows ? 'PM' : 'AM'}';
}
return '${hour <= 9 ? 0 : ''}$hour'
':${minute <= 9 ? 0 : ''}$minute';
}
String get timeUntil {
int minutes = difference(DateTime.now()).inMinutes;
int hours = minutes ~/ 60;
minutes %= 60;
int days = hours ~/ 24;
hours %= 24;
return '${days < 1 ? "" : "${days}d "}'
'${hours < 1 ? "" : "${hours}h "}'
'${minutes < 1 ? "" : "${minutes}m"}';
}
static String monthName(int month) => switch (month) {
1 => 'Jan',
2 => 'Feb',
3 => 'Mar',
4 => 'Apr',
5 => 'May',
6 => 'Jun',
7 => 'Jul',
8 => 'Aug',
9 => 'Sep',
10 => 'Oct',
11 => 'Nov',
_ => 'Dec',
};
static String _weekdayName(int weekday) => switch (weekday) {
1 => 'Mon',
2 => 'Tue',
3 => 'Wed',
4 => 'Thu',
5 => 'Fri',
6 => 'Sat',
_ => 'Sun',
};
}
================================================
FILE: lib/extension/enum_extension.dart
================================================
extension EnumExtension on Iterable {
T? getOrNull(int? index) {
if (index != null && index >= 0 && index < length) {
return elementAt(index);
}
return null;
}
T getOrFirst(int? index) {
if (index != null && index >= 0 && index < length) {
return elementAt(index);
}
return first;
}
}
================================================
FILE: lib/extension/filter_chip_extension.dart
================================================
import 'package:flutter/material.dart';
extension FilterChipExtension on FilterChip {
static final highContrast = (bool highContrast) =>
highContrast ? FilterChip.new : FilterChip.elevated;
}
================================================
FILE: lib/extension/future_extension.dart
================================================
extension FutureExtension on Future {
Future getErrorOrNull() => then((_) => null, onError: (e) => e);
}
================================================
FILE: lib/extension/iterable_extension.dart
================================================
extension IterableExtension on Iterable {
E? firstWhereOrNull(bool Function(E) test) {
for (E element in this) {
if (test(element)) return element;
}
return null;
}
}
================================================
FILE: lib/extension/scroll_controller_extension.dart
================================================
import 'package:flutter/widgets.dart';
extension ScrollControllerExtension on ScrollController {
/// Scroll to the top with an animation.
Future scrollToTop() async {
if (!hasClients || positions.last.pixels <= 0) return;
if (positions.last.pixels > 100) positions.last.jumpTo(100);
await positions.last.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.decelerate,
);
}
}
================================================
FILE: lib/extension/snack_bar_extension.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
extension SnackBarExtension on SnackBar {
static ScaffoldFeatureController show(
BuildContext context,
String text, {
bool canCopyText = false,
}) {
return ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(text),
behavior: SnackBarBehavior.floating,
duration: const Duration(milliseconds: 2000),
persist: false,
action: canCopyText
? SnackBarAction(
label: 'Copy',
onPressed: () => Clipboard.setData(ClipboardData(text: text)),
)
: null,
),
);
}
/// Copy [text] to clipboard and notify with a snackbar.
static void copy(BuildContext context, String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) show(context, 'Copied');
}
/// Launch [link] in the browser or show a snackbar if unsuccessful.
static Future launch(BuildContext context, String link) async {
try {
final ok = await launchUrl(
Uri.parse(link),
mode: link.startsWith("https://anilist.co")
? LaunchMode.inAppBrowserView
: LaunchMode.externalApplication,
);
if (ok) return true;
} catch (_) {}
if (context.mounted) show(context, 'Could not open link');
return false;
}
}
================================================
FILE: lib/extension/string_extension.dart
================================================
import 'package:otraku/extension/date_time_extension.dart';
extension StringExtension on String {
static String? languageToCode(String? language) => switch (language) {
'Japanese' => 'JP',
'Chinese' => 'CN',
'Korean' => 'KR',
'French' => 'FR',
'Spanish' => 'ES',
'Italian' => 'IT',
'Portuguese' => 'PT',
'German' => 'DE',
_ => null,
};
static String? tryNoScreamingSnakeCase(dynamic str) =>
str is String ? str.noScreamingSnakeCase : null;
static final _ampersand = '&'.codeUnitAt(0);
static final _hashtag = '#'.codeUnitAt(0);
static final _semicolon = ';'.codeUnitAt(0);
/// AniList can't handle some unicode characters, so before uploading text,
/// symbols that are too big should be represented as HTML character entity
/// references. Important primarily for emojis, hence the name.
String get withParsedEmojis {
final parsedRunes = [];
for (final c in runes.toList()) {
if (c > 0xFFFF) {
parsedRunes.addAll([_ampersand, _hashtag, ...c.toString().codeUnits, _semicolon]);
} else {
parsedRunes.add(c);
}
}
return String.fromCharCodes(parsedRunes);
}
String get noScreamingSnakeCase => splitMapJoin(
'_',
onMatch: (_) => ' ',
onNonMatch: (s) => s[0].toUpperCase() + s.substring(1).toLowerCase(),
);
static String? fromFuzzyDate(Map? map) {
if (map?['year'] == null) return null;
final year = map!['year'];
final month = map['month'];
final day = map['day'];
return '${day != null ? '$day ' : ''}${month != null ? '${DateTimeExtension.monthName(month)} ' : ''}$year';
}
}
================================================
FILE: lib/feature/activity/activities_filter_model.dart
================================================
import 'package:otraku/extension/enum_extension.dart';
sealed class ActivitiesFilter {
const ActivitiesFilter();
ActivitiesFilter copy();
Map toGraphQlVariables();
}
class HomeActivitiesFilter extends ActivitiesFilter {
const HomeActivitiesFilter(
this.viewerId,
this.onFollowing,
this.withViewerActivities,
this.typeIn,
);
factory HomeActivitiesFilter.empty() =>
const HomeActivitiesFilter(null, false, false, [.animeStatus, .mangaStatus, .status]);
factory HomeActivitiesFilter.fromPersistenceMap(Map map, int? viewerId) {
final List typeIn =
map['activityTypeIn'] ??
[ActivityType.status.index, ActivityType.animeStatus.index, ActivityType.mangaStatus.index];
return HomeActivitiesFilter(
viewerId,
map['onFollowing'] ?? false,
map['withViewerActivities'] ?? false,
typeIn.map((index) => ActivityType.values.getOrFirst(index)).toList(),
);
}
final int? viewerId;
final bool onFollowing;
final bool withViewerActivities;
final List typeIn;
@override
HomeActivitiesFilter copy() =>
HomeActivitiesFilter(viewerId, onFollowing, withViewerActivities, [...typeIn]);
HomeActivitiesFilter copyWith({
bool? onFollowing,
bool? withViewerActivities,
List? typeIn,
}) => HomeActivitiesFilter(
viewerId,
onFollowing ?? this.onFollowing,
withViewerActivities ?? this.withViewerActivities,
typeIn ?? this.typeIn,
);
@override
Map toGraphQlVariables() => {
'isFollowing': onFollowing,
if (!onFollowing) 'hasRepliesOrText': true,
if (!withViewerActivities && viewerId != null) 'userIdNot': viewerId,
'typeIn': typeIn.map((t) => t.value).toList(),
};
Map toPersistenceMap() => {
'onFollowing': onFollowing,
'withViewerActivities': withViewerActivities,
'activityTypeIn': typeIn.map((a) => a.index).toList(),
};
}
class UserActivitiesFilter extends ActivitiesFilter {
const UserActivitiesFilter(this.userId, this.typeIn);
final int userId;
final List typeIn;
@override
UserActivitiesFilter copy() => UserActivitiesFilter(userId, [...typeIn]);
UserActivitiesFilter copyWithTypeIn(List typeIn) =>
UserActivitiesFilter(userId, typeIn);
@override
Map toGraphQlVariables() => {
'userId': userId,
'typeIn': typeIn.map((t) => t.value).toList(),
};
}
class MediaActivitiesFilter extends ActivitiesFilter {
const MediaActivitiesFilter(this.socialGroup, this.mediaId, this.viewerId);
factory MediaActivitiesFilter.empty() => const MediaActivitiesFilter(.global, 0, null);
final int mediaId;
final ActivitySocialGroup socialGroup;
final int? viewerId;
@override
MediaActivitiesFilter copy() => MediaActivitiesFilter(socialGroup, mediaId, viewerId);
MediaActivitiesFilter copyWith({
ActivitySocialGroup? socialGroup,
int? mediaId,
(int?,)? viewerId,
}) => MediaActivitiesFilter(
socialGroup ?? this.socialGroup,
mediaId ?? this.mediaId,
viewerId != null ? viewerId.$1 : this.viewerId,
);
@override
Map toGraphQlVariables() => {
'mediaId': mediaId,
...switch (socialGroup) {
.global => const {},
.followed => const {'isFollowing': true},
.self => viewerId != null ? {'userId': viewerId} : {'isFollowing': true},
},
};
Map toPersistenceMap() => {'socialGroup': socialGroup.index};
static MediaActivitiesFilter fromPersistence(
Map map,
int mediaId,
int? viewerId,
) => MediaActivitiesFilter(
ActivitySocialGroup.values.getOrFirst(map['socialGroup']),
mediaId,
viewerId,
);
}
enum ActivityType {
status('Statuses', 'TEXT'),
animeStatus('Anime Progress', 'ANIME_LIST'),
mangaStatus('Manga Progress', 'MANGA_LIST'),
message('Messages', 'MESSAGE');
const ActivityType(this.label, this.value);
final String label;
final String value;
}
enum ActivitySocialGroup { global, followed, self }
================================================
FILE: lib/feature/activity/activities_filter_provider.dart
================================================
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/feature/activity/activities_filter_model.dart';
import 'package:otraku/feature/activity/activities_model.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
final activitiesFilterProvider = NotifierProvider.autoDispose
.family(
ActivitiesFilterNotifier.new,
);
class ActivitiesFilterNotifier extends Notifier {
ActivitiesFilterNotifier(this.arg);
final ActivitiesTag arg;
@override
ActivitiesFilter build() => switch (arg) {
HomeActivitiesTag _ => ref.watch(persistenceProvider.select((s) => s.homeActivitiesFilter)),
UserActivitiesTag(:final userId) => UserActivitiesFilter(userId, ActivityType.values),
MediaActivitiesTag(:final mediaId) =>
ref
.watch(persistenceProvider.select((s) => s.mediaActivitiesFilter))
.copyWith(mediaId: mediaId, viewerId: (ref.watch(viewerIdProvider),)),
};
@override
set state(ActivitiesFilter newState) {
if (state == newState) return;
switch (newState) {
case HomeActivitiesFilter homeActivitiesFilter:
ref.read(persistenceProvider.notifier).setHomeActivitiesFilter(homeActivitiesFilter);
case MediaActivitiesFilter mediaActivitiesFilter:
ref.read(persistenceProvider.notifier).setMediaActivitiesFilter(mediaActivitiesFilter);
case UserActivitiesFilter _:
super.state = newState;
}
}
}
================================================
FILE: lib/feature/activity/activities_model.dart
================================================
sealed class ActivitiesTag {
const ActivitiesTag();
String toQueryParam() => switch (this) {
HomeActivitiesTag() => 'home',
UserActivitiesTag(:final userId) => 'user:$userId',
MediaActivitiesTag(:final mediaId) => 'media:$mediaId',
};
static ActivitiesTag? fromQueryParam(String param) {
if (param == 'home') {
return HomeActivitiesTag.instance;
} else if (param.startsWith('user:')) {
final userId = int.tryParse(param.substring(5));
if (userId != null) {
return UserActivitiesTag(userId);
}
} else if (param.startsWith('media:')) {
final mediaId = int.tryParse(param.substring(6));
if (mediaId != null) {
return MediaActivitiesTag(mediaId);
}
}
return null;
}
}
class HomeActivitiesTag extends ActivitiesTag {
const HomeActivitiesTag._();
static const instance = HomeActivitiesTag._();
}
class UserActivitiesTag extends ActivitiesTag {
const UserActivitiesTag(this.userId);
final int userId;
@override
bool operator ==(Object other) => other is UserActivitiesTag && userId == other.userId;
@override
int get hashCode => userId.hashCode;
}
class MediaActivitiesTag extends ActivitiesTag {
const MediaActivitiesTag(this.mediaId);
final int mediaId;
@override
bool operator ==(Object other) => other is MediaActivitiesTag && mediaId == other.mediaId;
@override
int get hashCode => mediaId.hashCode;
}
================================================
FILE: lib/feature/activity/activities_provider.dart
================================================
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/extension/future_extension.dart';
import 'package:otraku/feature/activity/activities_filter_model.dart';
import 'package:otraku/feature/activity/activities_filter_provider.dart';
import 'package:otraku/feature/activity/activities_model.dart';
import 'package:otraku/feature/activity/activity_model.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/feature/viewer/repository_provider.dart';
import 'package:otraku/util/paged.dart';
import 'package:otraku/util/graphql.dart';
final activitiesProvider = AsyncNotifierProvider.autoDispose
.family, ActivitiesTag>(ActivitiesNotifier.new);
class ActivitiesNotifier extends AsyncNotifier> {
ActivitiesNotifier(this.arg);
final ActivitiesTag arg;
int? _viewerId;
late ActivitiesFilter _filter;
// Used to skip activities when fetching outdated pages.
int? _lastId;
@override
FutureOr> build() {
// The home feed and the media feeds are lazy-loaded. The home feed is never disposed,
// while the media feeds are disposed only when the media page is popped.
if (arg is HomeActivitiesTag || arg is MediaActivitiesTag) {
ref.keepAlive();
}
_lastId = null;
_filter = ref.watch(activitiesFilterProvider(arg));
_viewerId = ref.watch(viewerIdProvider);
return _fetch(const Paged());
}
Future fetch() async {
final oldState = state.value ?? const Paged();
if (!oldState.hasNext) return;
state = await AsyncValue.guard(() => _fetch(oldState));
}
Future> _fetch(Paged oldState) async {
final data = await ref.read(repositoryProvider).request(GqlQuery.activityPage, {
'page': oldState.next,
..._filter.toGraphQlVariables(),
});
final imageQuality = ref.read(persistenceProvider).options.imageQuality;
final items = [];
for (final a in data['Page']['activities']) {
if (_lastId != null && a['id'] >= _lastId) continue;
final item = Activity.maybe(a, _viewerId, imageQuality);
if (item != null) items.add(item);
}
if (data['Page']['activities'].isNotEmpty) {
_lastId = data['Page']['activities'].last['id'];
}
return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false);
}
void prepend(Map map) {
final value = state.value;
if (value == null) return;
final activity = Activity.maybe(
map,
_viewerId,
ref.read(persistenceProvider).options.imageQuality,
);
if (activity == null) return;
state = AsyncValue.data(
Paged(items: [activity, ...value.items], hasNext: value.hasNext, next: value.next),
);
}
void replace(Activity activity) {
final value = state.value;
if (value == null) return;
for (int i = 0; i < value.items.length; i++) {
if (value.items[i].id == activity.id) {
value.items[i] = activity;
state = AsyncValue.data(
Paged(items: value.items, hasNext: value.hasNext, next: value.next),
);
return;
}
}
}
Future toggleLike(Activity activity) async {
final err = await ref.read(repositoryProvider).request(GqlMutation.toggleLike, {
'id': activity.id,
'type': 'ACTIVITY',
}).getErrorOrNull();
if (err != null) return err;
replace(activity);
return null;
}
Future toggleSubscription(Activity activity) async {
final err = await ref.read(repositoryProvider).request(GqlMutation.toggleActivitySubscription, {
'id': activity.id,
'subscribe': activity.isSubscribed,
}).getErrorOrNull();
if (err != null) return err;
replace(activity);
return null;
}
Future togglePin(Activity activity) async {
final err = await ref.read(repositoryProvider).request(GqlMutation.toggleActivityPin, {
'id': activity.id,
'pinned': activity.isPinned,
}).getErrorOrNull();
if (err != null) return err;
final value = state.value;
if (value == null) return null;
for (int i = 0; i < value.items.length; i++) {
if (value.items[i].id == activity.id) {
// Unpin previously pinned activity.
if (value.items.length > 1) {
value.items[0].isPinned = false;
}
// Move newly pinned activity to the top.
for (int j = i - 1; j >= 0; j--) {
value.items[j + 1] = value.items[j];
}
value.items[0] = activity;
state = AsyncValue.data(
Paged(items: value.items, hasNext: value.hasNext, next: value.next),
);
break;
}
}
return null;
}
Future remove(Activity activity) async {
final err = await ref.read(repositoryProvider).request(GqlMutation.deleteActivity, {
'id': activity.id,
}).getErrorOrNull();
if (err != null) return err;
final value = state.value;
if (value == null) return null;
for (int i = 0; i < value.items.length; i++) {
if (value.items[i].id == activity.id) {
value.items.removeAt(i);
state = AsyncValue.data(
Paged(items: value.items, hasNext: value.hasNext, next: value.next),
);
break;
}
}
return null;
}
}
================================================
FILE: lib/feature/activity/activities_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/feature/activity/activities_model.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/feature/activity/activity_filter_sheet.dart';
import 'package:otraku/feature/activity/activities_provider.dart';
import 'package:otraku/feature/activity/activity_card.dart';
import 'package:otraku/feature/composition/composition_model.dart';
import 'package:otraku/feature/composition/composition_view.dart';
import 'package:otraku/feature/settings/settings_provider.dart';
import 'package:otraku/feature/activity/activity_model.dart';
import 'package:otraku/util/paged_controller.dart';
import 'package:otraku/widget/layout/adaptive_scaffold.dart';
import 'package:otraku/widget/layout/hiding_floating_action_button.dart';
import 'package:otraku/widget/layout/top_bar.dart';
import 'package:otraku/widget/sheets.dart';
import 'package:otraku/widget/paged_view.dart';
class ActivitiesView extends ConsumerStatefulWidget {
const ActivitiesView(this.tag);
final UserActivitiesTag tag;
@override
ConsumerState createState() => _ActivitiesViewState();
}
class _ActivitiesViewState extends ConsumerState {
late final _scrollCtrl = PagedController(
loadMore: () => ref.read(activitiesProvider(widget.tag).notifier).fetch(),
);
@override
void dispose() {
_scrollCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final viewerId = ref.watch(viewerIdProvider);
final userId = widget.tag.userId;
final floatingAction = viewerId != null
? HidingFloatingActionButton(
key: const Key('post'),
scrollCtrl: _scrollCtrl,
child: FloatingActionButton(
tooltip: userId == viewerId ? 'New Post' : 'New Message',
child: const Icon(Icons.edit_outlined),
onPressed: () => showSheet(
context,
CompositionView(
tag: userId == viewerId
? const StatusActivityCompositionTag(id: null)
: MessageActivityCompositionTag(id: null, recipientId: userId),
onSaved: (map) => ref.read(activitiesProvider(widget.tag).notifier).prepend(map),
),
),
),
)
: null;
return AdaptiveScaffold(
topBar: TopBar(
title: 'Activities',
trailing: [
IconButton(
tooltip: 'Filter',
icon: const Icon(Ionicons.funnel_outline),
onPressed: () => showActivityFilterSheet(context, ref, widget.tag),
),
],
),
floatingAction: floatingAction,
child: ActivitiesSubView(widget.tag, _scrollCtrl),
);
}
}
class ActivitiesSubView extends StatelessWidget {
const ActivitiesSubView(this.tag, this.scrollCtrl);
final ActivitiesTag tag;
final ScrollController scrollCtrl;
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, _) {
final viewerId = ref.watch(viewerIdProvider);
final options = ref.watch(persistenceProvider.select((s) => s.options));
return PagedView(
provider: activitiesProvider(
tag,
).select((s) => s.unwrapPrevious().whenData((data) => data)),
scrollCtrl: scrollCtrl,
onRefresh: (invalidate) {
invalidate(activitiesProvider(tag));
if (tag is HomeActivitiesTag) {
ref.read(settingsProvider.notifier).refetchUnread();
}
},
onData: (data) => SliverList(
delegate: SliverChildBuilderDelegate(
childCount: data.items.length,
(context, i) => ActivityCard(
withHeader: true,
analogClock: options.analogClock,
highContrast: options.highContrast,
activity: data.items[i],
footer: ActivityFooter(
viewerId: viewerId,
activity: data.items[i],
toggleLike: () =>
ref.read(activitiesProvider(tag).notifier).toggleLike(data.items[i]),
toggleSubscription: () =>
ref.read(activitiesProvider(tag).notifier).toggleSubscription(data.items[i]),
togglePin: () =>
ref.read(activitiesProvider(tag).notifier).togglePin(data.items[i]),
remove: () => ref.read(activitiesProvider(tag).notifier).remove(data.items[i]),
onEdited: (map) {
final activity = Activity.maybe(map, viewerId, options.imageQuality);
if (activity == null) return;
ref.read(activitiesProvider(tag).notifier).replace(activity);
},
reply: () => context.push(Routes.activity(data.items[i].id, tag)),
),
),
),
),
);
},
);
}
}
================================================
FILE: lib/feature/activity/activity_card.dart
================================================
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/extension/build_context_extension.dart';
import 'package:otraku/extension/card_extension.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/feature/activity/activity_model.dart';
import 'package:otraku/feature/composition/composition_model.dart';
import 'package:otraku/feature/composition/composition_view.dart';
import 'package:otraku/feature/media/media_route_tile.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/cached_image.dart';
import 'package:otraku/widget/html_content.dart';
import 'package:otraku/widget/dialogs.dart';
import 'package:otraku/widget/sheets.dart';
import 'package:otraku/widget/timestamp.dart';
class ActivityCard extends StatelessWidget {
const ActivityCard({
required this.activity,
required this.footer,
required this.withHeader,
required this.analogClock,
required this.highContrast,
});
final Activity activity;
final ActivityFooter footer;
final bool withHeader;
final bool analogClock;
final bool highContrast;
@override
Widget build(BuildContext context) {
final body = CardExtension.highContrast(highContrast)(
margin: const .only(bottom: Theming.offset),
child: Padding(
padding: const .only(top: Theming.offset, left: Theming.offset, right: Theming.offset),
child: Column(
children: [
if (activity is MediaActivity)
_ActivityMediaBox(activity as MediaActivity)
else
HtmlContent(activity.text),
Row(
mainAxisAlignment: .spaceBetween,
spacing: 5,
children: [
Flexible(child: Timestamp(activity.createdAt, analogClock)),
footer,
],
),
],
),
),
);
if (!withHeader) return body;
const avatarSize = 50.0;
return Column(
crossAxisAlignment: .start,
children: [
Row(
children: [
Flexible(
child: GestureDetector(
behavior: .opaque,
onTap: () => context.push(Routes.user(activity.authorId, activity.authorAvatarUrl)),
child: Row(
mainAxisSize: .min,
spacing: Theming.offset,
children: [
ClipRRect(
borderRadius: Theming.borderRadiusSmall,
child: CachedImage(
activity.authorAvatarUrl,
height: avatarSize,
width: avatarSize,
),
),
Flexible(child: Text(activity.authorName, overflow: .ellipsis, maxLines: 1)),
],
),
),
),
...switch (activity) {
MessageActivity message => [
if (message.isPrivate)
const Padding(
padding: .only(left: Theming.offset),
child: Icon(Ionicons.eye_off_outline),
),
const Padding(
padding: .symmetric(horizontal: Theming.offset),
child: Icon(Icons.arrow_right_alt),
),
GestureDetector(
behavior: .opaque,
onTap: () =>
context.push(Routes.user(message.recipientId, message.recipientAvatarUrl)),
child: ClipRRect(
borderRadius: Theming.borderRadiusSmall,
child: CachedImage(
message.recipientAvatarUrl,
height: avatarSize,
width: avatarSize,
),
),
),
],
_ when activity.isPinned => const [
Padding(
padding: .only(left: Theming.offset),
child: Icon(Icons.push_pin_outlined),
),
],
_ => const [],
},
],
),
const SizedBox(height: 5),
body,
],
);
}
}
class _ActivityMediaBox extends StatelessWidget {
const _ActivityMediaBox(this.item);
final MediaActivity item;
@override
Widget build(BuildContext context) {
final textTheme = TextTheme.of(context);
final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);
final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);
final height = bodyMediumLineHeight * 3 + labelMediumLineHeight + 5;
return MediaRouteTile(
id: item.mediaId,
imageUrl: item.coverUrl,
child: SizedBox(
height: height,
child: Row(
children: [
ClipRRect(
borderRadius: Theming.borderRadiusSmall,
child: CachedImage(item.coverUrl, width: height / Theming.coverHtoWRatio),
),
Expanded(
child: Padding(
padding: const .symmetric(horizontal: Theming.offset),
child: Column(
mainAxisAlignment: .spaceEvenly,
crossAxisAlignment: .start,
spacing: 5,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(text: item.text, style: textTheme.labelMedium),
TextSpan(text: item.title, style: textTheme.bodyMedium),
],
),
overflow: .ellipsis,
maxLines: 3,
),
if (item.format != null)
Text(
item.format!,
style: textTheme.labelMedium,
overflow: .ellipsis,
maxLines: 1,
),
],
),
),
),
],
),
),
);
}
}
class ActivityFooter extends StatefulWidget {
const ActivityFooter({
required this.viewerId,
required this.activity,
required this.remove,
required this.togglePin,
required this.toggleLike,
required this.toggleSubscription,
required this.reply,
required this.onEdited,
});
final int? viewerId;
final Activity activity;
final Future Function() remove;
final Future Function() toggleLike;
final Future Function() toggleSubscription;
final Future Function() togglePin;
final Future Function()? reply;
final void Function(Map)? onEdited;
@override
State createState() => _ActivityFooterState();
}
class _ActivityFooterState extends State {
@override
Widget build(BuildContext context) {
final activity = widget.activity;
return Row(
children: [
SizedBox(
height: 40,
child: Tooltip(
message: 'More',
child: InkResponse(
radius: Theming.radiusSmall.x,
onTap: _showMoreSheet,
child: const Icon(Ionicons.ellipsis_horizontal, size: Theming.iconSmall),
),
),
),
const SizedBox(width: Theming.offset),
SizedBox(
height: 40,
child: Tooltip(
message: 'Replies',
child: InkResponse(
radius: Theming.radiusSmall.x,
onTap: widget.reply,
child: Row(
children: [
Text(activity.replyCount.toString(), style: TextTheme.of(context).labelSmall),
const SizedBox(width: 5),
const Icon(Icons.reply_all_rounded, size: Theming.iconSmall),
],
),
),
),
),
const SizedBox(width: Theming.offset),
SizedBox(
height: 40,
child: Tooltip(
message: !activity.isLiked ? 'Like' : 'Unlike',
child: InkResponse(
radius: Theming.radiusSmall.x,
onTap: _toggleLike,
child: Row(
children: [
Text(
activity.likeCount.toString(),
style: !activity.isLiked
? TextTheme.of(context).labelSmall
: TextTheme.of(
context,
).labelSmall!.copyWith(color: ColorScheme.of(context).primary),
),
const SizedBox(width: 5),
Icon(
!widget.activity.isLiked
? Icons.favorite_outline_rounded
: Icons.favorite_rounded,
size: Theming.iconSmall,
color: activity.isLiked ? ColorScheme.of(context).primary : null,
),
],
),
),
),
),
],
);
}
/// Show a sheet with additional options.
void _showMoreSheet() {
final activity = widget.activity;
showSheet(
context,
Consumer(
builder: (context, ref, _) {
final ownershipButtons = [];
if (activity.isOwned) {
if (activity is! MessageActivity) {
ownershipButtons.add(
ListTile(
title: activity.isPinned ? const Text('Unpin') : const Text('Pin'),
leading: activity.isPinned
? const Icon(Icons.push_pin)
: const Icon(Icons.push_pin_outlined),
onTap: _togglePin,
),
);
}
if (activity.authorId == widget.viewerId) {
switch (activity) {
case StatusActivity _:
ownershipButtons.add(
ListTile(
title: const Text('Edit'),
leading: const Icon(Icons.edit_outlined),
onTap: () => showSheet(
context,
CompositionView(
tag: StatusActivityCompositionTag(id: activity.id),
onSaved: (map) {
widget.onEdited?.call(map);
Navigator.pop(context);
},
),
),
),
);
case MessageActivity _:
ownershipButtons.add(
ListTile(
title: const Text('Edit'),
leading: const Icon(Icons.edit_outlined),
onTap: () => showSheet(
context,
CompositionView(
tag: MessageActivityCompositionTag(
id: activity.id,
recipientId: activity.recipientId,
),
onSaved: (map) {
widget.onEdited?.call(map);
Navigator.pop(context);
},
),
),
),
);
case MediaActivity _:
break;
}
}
ownershipButtons.add(
ListTile(
title: const Text('Delete'),
leading: const Icon(Ionicons.trash_outline),
onTap: () => ConfirmationDialog.show(
context,
title: 'Delete?',
primaryAction: 'Yes',
secondaryAction: 'No',
onConfirm: _remove,
),
),
);
}
return SimpleSheet.link(context, activity.siteUrl, [
...ownershipButtons,
ListTile(
title: !activity.isSubscribed ? const Text('Subscribe') : const Text('Unsubscribe'),
leading: !activity.isSubscribed
? const Icon(Ionicons.notifications_outline)
: const Icon(Ionicons.notifications_off_outline),
onTap: _toggleSubscription,
),
]);
},
),
);
}
void _toggleLike() async {
final activity = widget.activity;
final isLiked = activity.isLiked;
setState(() {
activity.isLiked = !isLiked;
activity.likeCount += isLiked ? -1 : 1;
});
final err = await widget.toggleLike();
if (err == null) return;
setState(() {
activity.isLiked = isLiked;
activity.likeCount += isLiked ? 1 : -1;
});
if (mounted) SnackBarExtension.show(context, err.toString());
}
void _toggleSubscription() {
final activity = widget.activity;
activity.isSubscribed = !activity.isSubscribed;
widget.toggleSubscription().then((err) {
if (err == null) {
if (mounted) Navigator.pop(context);
return;
}
activity.isSubscribed = !activity.isSubscribed;
if (mounted) {
SnackBarExtension.show(context, err.toString());
Navigator.pop(context);
}
});
}
void _togglePin() {
final activity = widget.activity;
activity.isPinned = !activity.isPinned;
widget.togglePin().then((err) {
if (err == null) {
if (mounted) Navigator.pop(context);
return;
}
activity.isPinned = !activity.isPinned;
if (mounted) {
SnackBarExtension.show(context, err.toString());
Navigator.pop(context);
}
});
}
void _remove() {
widget.remove().then((err) {
if (err == null) {
if (mounted) Navigator.pop(context);
return;
}
if (mounted) {
SnackBarExtension.show(context, err.toString());
Navigator.pop(context);
}
});
}
}
================================================
FILE: lib/feature/activity/activity_filter_sheet.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/feature/activity/activities_filter_model.dart';
import 'package:otraku/feature/activity/activities_model.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/sheets.dart';
import 'package:otraku/feature/activity/activities_filter_provider.dart';
void showActivityFilterSheet(BuildContext context, WidgetRef ref, ActivitiesTag tag) {
ActivitiesFilter filter = ref.read(activitiesFilterProvider(tag));
double initialHeight = Theming.normalTapTarget * ActivityType.values.length + Theming.offset;
if (filter is HomeActivitiesFilter) {
initialHeight += Theming.normalTapTarget * 2.5;
}
showSheet(
context,
SimpleSheet(
initialHeight: initialHeight,
builder: (context, scrollCtrl) =>
_FilterList(filter: filter, onChanged: (v) => filter = v, scrollCtrl: scrollCtrl),
),
).then((_) {
ref.read(activitiesFilterProvider(tag).notifier).state = filter;
});
}
class _FilterList extends StatefulWidget {
const _FilterList({required this.filter, required this.onChanged, required this.scrollCtrl});
final ActivitiesFilter filter;
final void Function(ActivitiesFilter) onChanged;
final ScrollController scrollCtrl;
@override
State<_FilterList> createState() => _FilterListState();
}
class _FilterListState extends State<_FilterList> {
late var _filter = widget.filter.copy();
@override
Widget build(BuildContext context) {
final typeIn = switch (_filter) {
HomeActivitiesFilter(:final typeIn) => typeIn,
UserActivitiesFilter(:final typeIn) => typeIn,
MediaActivitiesFilter _ => [],
};
return ListView(
controller: widget.scrollCtrl,
physics: Theming.bouncyPhysics,
padding: const .symmetric(vertical: Theming.offset),
children: [
for (final a in ActivityType.values)
CheckboxListTile(
title: Text(a.label),
value: typeIn.contains(a),
onChanged: (val) {
setState(() {
if (val == true) {
typeIn.add(a);
} else if (val == false) {
typeIn.remove(a);
}
});
widget.onChanged(_filter.copy());
},
),
...switch (_filter) {
UserActivitiesFilter _ || MediaActivitiesFilter _ => const [],
HomeActivitiesFilter filter => [
const Divider(),
CheckboxListTile(
title: const Text('My Activities'),
value: filter.withViewerActivities,
onChanged: (v) {
setState(() => _filter = filter.copyWith(withViewerActivities: v!));
widget.onChanged(_filter.copy());
},
),
Padding(
padding: const .only(
top: Theming.offset,
left: Theming.offset,
right: Theming.offset,
),
child: SegmentedButton(
segments: const [
ButtonSegment(
value: true,
label: Text('Following'),
icon: Icon(Ionicons.people_outline),
),
ButtonSegment(
value: false,
label: Text('Global'),
icon: Icon(Ionicons.planet_outline),
),
],
selected: {filter.onFollowing},
onSelectionChanged: (v) {
setState(() => _filter = filter.copyWith(onFollowing: v.first));
widget.onChanged(_filter.copy());
},
),
),
],
},
],
);
}
}
================================================
FILE: lib/feature/activity/activity_model.dart
================================================
import 'package:otraku/extension/date_time_extension.dart';
import 'package:otraku/extension/string_extension.dart';
import 'package:otraku/feature/viewer/persistence_model.dart';
import 'package:otraku/util/paged.dart';
import 'package:otraku/util/markdown.dart';
class ExpandedActivity {
ExpandedActivity(this.activity, this.replies);
final Activity activity;
final Paged replies;
}
sealed class Activity {
Activity({
required this.id,
required this.authorId,
required this.authorName,
required this.authorAvatarUrl,
required this.createdAt,
required this.text,
required this.siteUrl,
required this.isOwned,
required this.replyCount,
required this.likeCount,
required this.isLiked,
required this.isSubscribed,
required this.isPinned,
});
static Activity? maybe(Map map, int? viewerId, ImageQuality imageQuality) {
try {
switch (map['type']) {
case 'TEXT':
if (map['user'] == null) return null;
return StatusActivity(
id: map['id'],
authorId: map['user']['id'],
authorName: map['user']['name'],
authorAvatarUrl: map['user']['avatar']['large'],
siteUrl: map['siteUrl'],
text: parseMarkdown(map['text'] ?? ''),
createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),
isOwned: map['user']['id'] == viewerId,
replyCount: map['replyCount'] ?? 0,
likeCount: map['likeCount'] ?? 0,
isLiked: map['isLiked'] ?? false,
isSubscribed: map['isSubscribed'] ?? false,
isPinned: map['isPinned'] ?? false,
);
case 'MESSAGE':
if (map['messenger'] == null || map['recipient'] == null) return null;
return MessageActivity(
id: map['id'],
authorId: map['messenger']['id'],
authorName: map['messenger']['name'],
authorAvatarUrl: map['messenger']['avatar']['large'],
recipientId: map['recipient']['id'],
recipientName: map['recipient']['name'],
recipientAvatarUrl: map['recipient']['avatar']['large'],
siteUrl: map['siteUrl'],
text: parseMarkdown(map['message'] ?? ''),
createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),
isOwned: map['messenger']['id'] == viewerId || map['recipient']['id'] == viewerId,
isPrivate: map['isPrivate'] ?? false,
replyCount: map['replyCount'] ?? 0,
likeCount: map['likeCount'] ?? 0,
isLiked: map['isLiked'] ?? false,
isSubscribed: map['isSubscribed'] ?? false,
isPinned: false,
);
case 'ANIME_LIST':
case 'MANGA_LIST':
if (map['user'] == null || map['media'] == null) return null;
final progress = map['progress'] != null ? '${map['progress']} of ' : '';
final status =
(map['status'] as String)[0].toUpperCase() + (map['status'] as String).substring(1);
return MediaActivity(
id: map['id'],
authorId: map['user']['id'],
authorName: map['user']['name'],
authorAvatarUrl: map['user']['avatar']['large'],
mediaId: map['media']['id'],
title: map['media']['title']['userPreferred'],
coverUrl: map['media']['coverImage'][imageQuality.value],
format: StringExtension.tryNoScreamingSnakeCase(map['media']['format']),
isAnime: map['type'] == 'ANIME_LIST',
siteUrl: map['siteUrl'],
text: '$status $progress',
createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),
isOwned: map['user']['id'] == viewerId,
replyCount: map['replyCount'] ?? 0,
likeCount: map['likeCount'] ?? 0,
isLiked: map['isLiked'] ?? false,
isSubscribed: map['isSubscribed'] ?? false,
isPinned: map['isPinned'] ?? false,
);
default:
return null;
}
} catch (_) {
return null;
}
}
final int id;
final int authorId;
final String authorName;
final String authorAvatarUrl;
final String text;
final String siteUrl;
final DateTime createdAt;
final bool isOwned;
int replyCount;
int likeCount;
bool isLiked;
bool isSubscribed;
bool isPinned;
}
class StatusActivity extends Activity {
StatusActivity({
required super.id,
required super.authorId,
required super.authorName,
required super.authorAvatarUrl,
required super.createdAt,
required super.text,
required super.siteUrl,
required super.isOwned,
required super.replyCount,
required super.likeCount,
required super.isLiked,
required super.isSubscribed,
required super.isPinned,
});
}
class MessageActivity extends Activity {
MessageActivity({
required super.id,
required super.authorId,
required super.authorName,
required super.authorAvatarUrl,
required super.createdAt,
required super.text,
required super.siteUrl,
required super.isOwned,
required super.replyCount,
required super.likeCount,
required super.isLiked,
required super.isSubscribed,
required super.isPinned,
required this.recipientId,
required this.recipientName,
required this.recipientAvatarUrl,
required this.isPrivate,
});
final int recipientId;
final String recipientName;
final String recipientAvatarUrl;
final bool isPrivate;
}
class MediaActivity extends Activity {
MediaActivity({
required super.id,
required super.authorId,
required super.authorName,
required super.authorAvatarUrl,
required super.createdAt,
required super.text,
required super.siteUrl,
required super.isOwned,
required super.replyCount,
required super.likeCount,
required super.isLiked,
required super.isSubscribed,
required super.isPinned,
required this.mediaId,
required this.title,
required this.coverUrl,
required this.isAnime,
required this.format,
});
final int mediaId;
final String title;
final String coverUrl;
final bool isAnime;
final String? format;
}
class ActivityReply {
ActivityReply._({
required this.id,
required this.authorId,
required this.authorName,
required this.authorAvatarUrl,
required this.text,
required this.createdAt,
this.likeCount = 0,
this.isLiked = false,
});
static ActivityReply? maybe(Map map) {
if (map['id'] == null || map['user']?['id'] == null) return null;
return ActivityReply._(
id: map['id'],
authorId: map['user']['id'],
authorName: map['user']['name'],
authorAvatarUrl: map['user']['avatar']['large'],
text: parseMarkdown(map['text'] ?? ''),
createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),
likeCount: map['likeCount'] ?? 0,
isLiked: map['isLiked'] ?? false,
);
}
final int id;
final int authorId;
final String authorName;
final String authorAvatarUrl;
final String text;
final DateTime createdAt;
int likeCount;
bool isLiked;
}
================================================
FILE: lib/feature/activity/activity_provider.dart
================================================
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/extension/future_extension.dart';
import 'package:otraku/feature/activity/activity_model.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/feature/viewer/repository_provider.dart';
import 'package:otraku/util/graphql.dart';
import 'package:otraku/util/paged.dart';
final activityProvider = AsyncNotifierProvider.autoDispose
.family(ActivityNotifier.new);
class ActivityNotifier extends AsyncNotifier {
ActivityNotifier(this.arg);
final int arg;
int? _viewerId;
@override
FutureOr build() async {
_viewerId = ref.watch(viewerIdProvider);
return await _fetch(null);
}
Future fetch() async {
if (!(state.value?.replies.hasNext ?? true)) return;
state = await AsyncValue.guard(() => _fetch(state.value));
}
Future _fetch(ExpandedActivity? oldState) async {
final replies = oldState?.replies ?? const Paged();
final data = await ref.read(repositoryProvider).request(GqlQuery.activity, {
'id': arg,
'page': replies.next,
if (replies.next == 1) 'withActivity': true,
});
final items = [];
for (final r in data['Page']['activityReplies']) {
final item = ActivityReply.maybe(r);
if (item != null) items.add(item);
}
final activity =
oldState?.activity ??
Activity.maybe(
data['Activity'],
_viewerId,
ref.read(persistenceProvider).options.imageQuality,
);
if (activity == null) throw StateError('Could not parse activity');
return ExpandedActivity(
activity,
replies.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false),
);
}
void replace(Activity activity) {
final value = state.value;
if (value == null) return;
state = AsyncValue.data(ExpandedActivity(activity, value.replies));
}
void appendReply(Map map) {
final value = state.value;
if (value == null) return;
final reply = ActivityReply.maybe(map);
if (reply == null) return;
value.activity.replyCount++;
state = AsyncValue.data(
ExpandedActivity(
value.activity,
Paged(
items: [...value.replies.items, reply],
hasNext: value.replies.hasNext,
next: value.replies.next,
),
),
);
}
void replaceReply(Map map) {
final value = state.value;
if (value == null) return;
final reply = ActivityReply.maybe(map);
if (reply == null) return;
for (int i = 0; i < value.replies.items.length; i++) {
if (value.replies.items[i].id == reply.id) {
value.replies.items[i] = reply;
state = AsyncValue.data(
ExpandedActivity(
value.activity,
Paged(
items: value.replies.items,
hasNext: value.replies.hasNext,
next: value.replies.next,
),
),
);
return;
}
}
}
Future toggleLike() {
return ref.read(repositoryProvider).request(GqlMutation.toggleLike, {
'id': arg,
'type': 'ACTIVITY',
}).getErrorOrNull();
}
Future toggleSubscription() {
final isSubscribed = state.value?.activity.isSubscribed;
if (isSubscribed == null) return Future.value();
return ref.read(repositoryProvider).request(GqlMutation.toggleActivitySubscription, {
'id': arg,
'subscribe': isSubscribed,
}).getErrorOrNull();
}
Future togglePin() {
final isPinned = state.value?.activity.isPinned;
if (isPinned == null) return Future.value();
return ref.read(repositoryProvider).request(GqlMutation.toggleActivityPin, {
'id': arg,
'pinned': isPinned,
}).getErrorOrNull();
}
Future toggleReplyLike(int replyId) {
return ref.read(repositoryProvider).request(GqlMutation.toggleLike, {
'id': replyId,
'type': 'ACTIVITY_REPLY',
}).getErrorOrNull();
}
Future remove() {
return ref.read(repositoryProvider).request(GqlMutation.deleteActivity, {
'id': arg,
}).getErrorOrNull();
}
Future removeReply(int replyId) async {
final value = state.value;
if (value == null) return Future.value();
final err = await ref.read(repositoryProvider).request(GqlMutation.deleteActivityReply, {
'id': replyId,
}).getErrorOrNull();
if (err != null) return err;
for (int i = 0; i < value.replies.items.length; i++) {
if (value.replies.items[i].id == replyId) {
value.replies.items.removeAt(i);
value.activity.replyCount--;
state = AsyncValue.data(
ExpandedActivity(
value.activity,
Paged(
items: value.replies.items,
hasNext: value.replies.hasNext,
next: value.replies.next,
),
),
);
break;
}
}
return null;
}
}
================================================
FILE: lib/feature/activity/activity_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/feature/activity/activities_model.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/widget/layout/adaptive_scaffold.dart';
import 'package:otraku/widget/layout/constrained_view.dart';
import 'package:otraku/feature/activity/activities_provider.dart';
import 'package:otraku/feature/activity/activity_model.dart';
import 'package:otraku/feature/activity/activity_provider.dart';
import 'package:otraku/feature/activity/activity_card.dart';
import 'package:otraku/feature/activity/reply_card.dart';
import 'package:otraku/feature/composition/composition_model.dart';
import 'package:otraku/feature/composition/composition_view.dart';
import 'package:otraku/util/paged_controller.dart';
import 'package:otraku/widget/layout/hiding_floating_action_button.dart';
import 'package:otraku/widget/layout/top_bar.dart';
import 'package:otraku/widget/cached_image.dart';
import 'package:otraku/widget/loaders.dart';
import 'package:otraku/widget/sheets.dart';
class ActivityView extends ConsumerStatefulWidget {
const ActivityView(this.id, this.sourceTag);
final int id;
final ActivitiesTag? sourceTag;
@override
ConsumerState createState() => _ActivityViewState();
}
class _ActivityViewState extends ConsumerState {
late final _scrollCtrl = PagedController(
loadMore: () => ref.read(activityProvider(widget.id).notifier).fetch(),
);
@override
void dispose() {
_scrollCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final activity = ref.watch(activityProvider(widget.id).select((s) => s.value?.activity));
return AdaptiveScaffold(
topBar: TopBar(trailing: [if (activity != null) _TopBarContent(activity)]),
floatingAction: HidingFloatingActionButton(
key: const Key('Reply'),
scrollCtrl: _scrollCtrl,
child: FloatingActionButton(
tooltip: 'New Reply',
child: const Icon(Icons.edit_outlined),
onPressed: () => showSheet(
context,
CompositionView(
tag: ActivityReplyCompositionTag(id: null, activityId: widget.id),
onSaved: (map) => ref.read(activityProvider(widget.id).notifier).appendReply(map),
),
),
),
),
child: _View(id: widget.id, sourceTag: widget.sourceTag, scrollCtrl: _scrollCtrl),
);
}
}
class _TopBarContent extends StatelessWidget {
const _TopBarContent(this.activity);
final Activity activity;
@override
Widget build(BuildContext context) {
return Expanded(
child: Row(
children: [
Flexible(
child: GestureDetector(
behavior: .opaque,
onTap: () => context.push(Routes.user(activity.authorId, activity.authorAvatarUrl)),
child: Row(
mainAxisSize: .min,
children: [
Hero(
tag: activity.authorId,
child: ClipRRect(
borderRadius: Theming.borderRadiusSmall,
child: CachedImage(activity.authorAvatarUrl, height: 40, width: 40),
),
),
const SizedBox(width: Theming.offset),
Flexible(child: Text(activity.authorName, overflow: .ellipsis, maxLines: 1)),
],
),
),
),
...switch (activity) {
MessageActivity message => [
if (message.isPrivate)
const Padding(
padding: .only(left: Theming.offset),
child: Icon(Ionicons.eye_off_outline),
),
const Padding(
padding: .symmetric(horizontal: Theming.offset),
child: Icon(Icons.arrow_right_alt),
),
GestureDetector(
behavior: .opaque,
onTap: () =>
context.push(Routes.user(message.recipientId, message.recipientAvatarUrl)),
child: ClipRRect(
borderRadius: Theming.borderRadiusSmall,
child: CachedImage(message.recipientAvatarUrl, height: 40, width: 40),
),
),
],
_ when activity.isPinned => const [
Padding(
padding: .only(left: Theming.offset),
child: Icon(Icons.push_pin_outlined),
),
],
_ => const [],
},
],
),
);
}
}
class _View extends ConsumerWidget {
const _View({required this.id, required this.sourceTag, required this.scrollCtrl});
final int id;
final ActivitiesTag? sourceTag;
final PagedController scrollCtrl;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(
activityProvider(id),
(_, s) =>
s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())),
);
final viewerId = ref.watch(viewerIdProvider);
final options = ref.watch(persistenceProvider.select((s) => s.options));
return ref
.watch(activityProvider(id))
.unwrapPrevious()
.when(
loading: () => const Center(child: Loader()),
error: (_, _) => const Center(child: Text('Failed to load activity')),
data: (data) {
return ConstrainedView(
child: CustomScrollView(
physics: Theming.bouncyPhysics,
controller: scrollCtrl,
slivers: [
SliverRefreshControl(onRefresh: () => ref.invalidate(activityProvider(id))),
SliverToBoxAdapter(
child: ActivityCard(
withHeader: false,
analogClock: options.analogClock,
highContrast: options.highContrast,
activity: data.activity,
footer: ActivityFooter(
viewerId: viewerId,
activity: data.activity,
toggleLike: () => _toggleLike(ref, data.activity),
toggleSubscription: () => _toggleSubscription(ref, data.activity),
togglePin: () => _togglePin(ref, data.activity),
remove: () => _remove(context, ref, data.activity),
onEdited: (map) => _onEdited(ref, map),
reply: () => _reply(context, ref, data.activity),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
childCount: data.replies.items.length,
(context, i) => ReplyCard(
activityId: id,
analogClock: options.analogClock,
highContrast: options.highContrast,
reply: data.replies.items[i],
toggleLike: () => ref
.read(activityProvider(id).notifier)
.toggleReplyLike(data.replies.items[i].id),
),
),
),
SliverFooter(loading: data.replies.hasNext),
],
),
);
},
);
}
Future _toggleLike(WidgetRef ref, Activity activity) {
if (sourceTag != null) {
return ref.read(activitiesProvider(sourceTag!).notifier).toggleLike(activity);
}
return ref.read(activityProvider(id).notifier).toggleLike();
}
Future _toggleSubscription(WidgetRef ref, Activity activity) {
if (sourceTag != null) {
return ref.read(activitiesProvider(sourceTag!).notifier).toggleSubscription(activity);
}
return ref.read(activityProvider(id).notifier).toggleSubscription();
}
Future _togglePin(WidgetRef ref, Activity activity) {
if (sourceTag != null) {
return ref.read(activitiesProvider(sourceTag!).notifier).togglePin(activity);
}
return ref.read(activityProvider(id).notifier).togglePin();
}
Future _remove(BuildContext context, WidgetRef ref, Activity activity) {
Navigator.pop(context);
if (sourceTag != null) {
return ref.read(activitiesProvider(sourceTag!).notifier).remove(activity);
}
return ref.read(activityProvider(id).notifier).remove();
}
void _onEdited(WidgetRef ref, Map map) {
final persistence = ref.read(persistenceProvider);
final activity = Activity.maybe(
map,
persistence.accountGroup.account?.id,
persistence.options.imageQuality,
);
if (activity == null) return;
ref.read(activityProvider(id).notifier).replace(activity);
if (sourceTag != null) {
ref.read(activitiesProvider(sourceTag!).notifier).replace(activity);
}
}
Future _reply(BuildContext context, WidgetRef ref, Activity activity) {
return showSheet(
context,
CompositionView(
defaultText: '@${activity.authorName} ',
tag: ActivityReplyCompositionTag(id: null, activityId: id),
onSaved: (map) => ref.read(activityProvider(id).notifier).appendReply(map),
),
);
}
}
================================================
FILE: lib/feature/activity/reply_card.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/extension/card_extension.dart';
import 'package:otraku/feature/activity/activity_model.dart';
import 'package:otraku/feature/activity/activity_provider.dart';
import 'package:otraku/feature/composition/composition_model.dart';
import 'package:otraku/feature/composition/composition_view.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/widget/cached_image.dart';
import 'package:otraku/widget/html_content.dart';
import 'package:otraku/widget/dialogs.dart';
import 'package:otraku/widget/sheets.dart';
import 'package:otraku/widget/timestamp.dart';
class ReplyCard extends StatelessWidget {
const ReplyCard({
required this.activityId,
required this.reply,
required this.analogClock,
required this.highContrast,
required this.toggleLike,
});
final int activityId;
final ActivityReply reply;
final bool analogClock;
final bool highContrast;
final Future Function() toggleLike;
@override
Widget build(BuildContext context) {
const avatarSize = 50.0;
return Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
spacing: 5,
children: [
GestureDetector(
behavior: .opaque,
onTap: () => context.push(Routes.user(reply.authorId, reply.authorAvatarUrl)),
child: Row(
mainAxisSize: .min,
spacing: Theming.offset,
children: [
ClipRRect(
borderRadius: Theming.borderRadiusSmall,
child: CachedImage(reply.authorAvatarUrl, height: avatarSize, width: avatarSize),
),
Flexible(child: Text(reply.authorName, overflow: .ellipsis, maxLines: 1)),
],
),
),
CardExtension.highContrast(highContrast)(
margin: const .only(bottom: Theming.offset),
child: Padding(
padding: const .only(top: Theming.offset, left: Theming.offset, right: Theming.offset),
child: Column(
mainAxisSize: .min,
children: [
UnconstrainedBox(
constrainedAxis: Axis.horizontal,
alignment: Alignment.topLeft,
child: HtmlContent(reply.text),
),
Row(
mainAxisAlignment: .spaceBetween,
spacing: 5,
children: [
Expanded(child: Timestamp(reply.createdAt, analogClock)),
Consumer(
builder: (context, ref, _) => SizedBox(
height: 40,
child: reply.authorId == ref.watch(viewerIdProvider)
? Tooltip(
message: 'More',
child: InkResponse(
radius: Theming.radiusSmall.x,
onTap: () => _showMoreSheet(context, ref),
child: const Icon(
Ionicons.ellipsis_horizontal,
size: Theming.iconSmall,
),
),
)
: _ReplyMentionButton(ref, activityId, reply.authorName),
),
),
_ReplyLikeButton(reply: reply, toggleLike: toggleLike),
],
),
],
),
),
),
],
);
}
/// Show a sheet with additional options.
void _showMoreSheet(BuildContext context, WidgetRef ref) {
showSheet(
context,
SimpleSheet.list([
ListTile(
title: const Text('Edit'),
leading: const Icon(Icons.edit_outlined),
onTap: () => showSheet(
context,
CompositionView(
tag: ActivityReplyCompositionTag(id: reply.id, activityId: activityId),
onSaved: (map) {
ref.read(activityProvider(activityId).notifier).replaceReply(map);
Navigator.pop(context);
},
),
),
),
ListTile(
title: const Text('Delete'),
leading: const Icon(Ionicons.trash_outline),
onTap: () => ConfirmationDialog.show(
context,
title: 'Delete?',
primaryAction: 'Yes',
secondaryAction: 'No',
onConfirm: () async {
final err = await ref
.read(activityProvider(activityId).notifier)
.removeReply(reply.id);
if (err == null) {
if (context.mounted) Navigator.pop(context);
return;
}
if (context.mounted) {
SnackBarExtension.show(context, err.toString());
Navigator.pop(context);
}
},
),
),
]),
);
}
}
class _ReplyMentionButton extends StatelessWidget {
const _ReplyMentionButton(this.ref, this.activityId, this.username);
final WidgetRef ref;
final int activityId;
final String username;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 40,
child: Tooltip(
message: 'Reply',
child: InkResponse(
radius: Theming.radiusSmall.x,
onTap: () => showSheet(
context,
CompositionView(
defaultText: '@$username ',
tag: ActivityReplyCompositionTag(id: null, activityId: activityId),
onSaved: (map) => ref.read(activityProvider(activityId).notifier).appendReply(map),
),
),
child: const Icon(Icons.reply_rounded, size: Theming.iconSmall),
),
),
);
}
}
class _ReplyLikeButton extends StatefulWidget {
const _ReplyLikeButton({required this.reply, required this.toggleLike});
final ActivityReply reply;
final Future Function() toggleLike;
@override
_ReplyLikeButtonState createState() => _ReplyLikeButtonState();
}
class _ReplyLikeButtonState extends State<_ReplyLikeButton> {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 40,
child: Tooltip(
message: !widget.reply.isLiked ? 'Like' : 'Unlike',
child: InkResponse(
radius: Theming.radiusSmall.x,
onTap: _toggleLike,
child: Row(
children: [
Text(
widget.reply.likeCount.toString(),
style: !widget.reply.isLiked
? TextTheme.of(context).labelSmall
: TextTheme.of(
context,
).labelSmall!.copyWith(color: ColorScheme.of(context).primary),
),
const SizedBox(width: 5),
Icon(
!widget.reply.isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded,
size: Theming.iconSmall,
color: widget.reply.isLiked ? ColorScheme.of(context).primary : null,
),
],
),
),
),
);
}
void _toggleLike() async {
final reply = widget.reply;
final isLiked = reply.isLiked;
setState(() {
reply.isLiked = !isLiked;
reply.likeCount += isLiked ? -1 : 1;
});
final err = await widget.toggleLike();
if (err == null) return;
setState(() {
reply.isLiked = isLiked;
reply.likeCount += isLiked ? 1 : -1;
});
if (mounted) SnackBarExtension.show(context, err.toString());
}
}
================================================
FILE: lib/feature/calendar/calendar_filter_provider.dart
================================================
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/feature/calendar/calendar_models.dart';
final calendarFilterProvider = NotifierProvider.autoDispose(
CalendarFilterNotifier.new,
);
class CalendarFilterNotifier extends Notifier {
@override
CalendarFilter build() => ref.watch(persistenceProvider.select((s) => s.calendarFilter));
@override
set state(CalendarFilter newState) {
ref.read(persistenceProvider.notifier).setCalendarFilter(newState);
}
}
================================================
FILE: lib/feature/calendar/calendar_filter_sheet.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/sheets.dart';
import 'package:otraku/feature/calendar/calendar_filter_provider.dart';
import 'package:otraku/feature/calendar/calendar_models.dart';
import 'package:otraku/widget/input/chip_selector.dart';
void showCalendarFilterSheet(BuildContext context, WidgetRef ref) {
final highContrast = ref.read(persistenceProvider.select((s) => s.options.highContrast));
final filter = ref.read(calendarFilterProvider);
CalendarSeasonFilter season = filter.season;
CalendarStatusFilter status = filter.status;
showSheet(
context,
SimpleSheet(
initialHeight: Theming.normalTapTarget * 2 + MediaQuery.paddingOf(context).bottom + 40,
builder: (context, scrollCtrl) => ListView(
controller: scrollCtrl,
physics: Theming.bouncyPhysics,
padding: const .symmetric(horizontal: Theming.offset, vertical: 20),
children: [
ChipSelector(
title: 'Season',
items: CalendarSeasonFilter.values.skip(1).map((v) => (v.label, v)).toList(),
value: season != .all ? season : null,
onChanged: (v) => season = v ?? .all,
highContrast: highContrast,
),
ChipSelector(
title: 'Status',
items: CalendarStatusFilter.values.skip(1).map((v) => (v.label, v)).toList(),
value: status != .all ? status : null,
onChanged: (v) => status = v ?? .all,
highContrast: highContrast,
),
],
),
),
).then((_) {
if (season != filter.season || status != filter.status) {
ref.read(calendarFilterProvider.notifier).state = filter.copyWith(
season: season,
status: status,
);
}
});
}
================================================
FILE: lib/feature/calendar/calendar_models.dart
================================================
import 'package:flutter/widgets.dart';
import 'package:otraku/extension/color_extension.dart';
import 'package:otraku/extension/date_time_extension.dart';
import 'package:otraku/extension/enum_extension.dart';
import 'package:otraku/feature/viewer/persistence_model.dart';
import 'package:otraku/feature/collection/collection_models.dart';
class CalendarItem {
const CalendarItem._({
required this.mediaId,
required this.title,
required this.cover,
required this.episode,
required this.airingAt,
required this.entryStatus,
required this.streamingServices,
});
factory CalendarItem(Map map, ImageQuality imageQuality) {
final streamingServices = [];
if (map['media']['externalLinks'] != null) {
for (final link in map['media']['externalLinks']) {
if (link['type'] == 'STREAMING') {
streamingServices.add((
url: link['url'],
site: link['site'],
color: link['color'] != null ? ColorExtension.fromHexString(link['color']) : null,
));
}
}
}
return CalendarItem._(
mediaId: map['mediaId'],
title: map['media']['title']['userPreferred'],
cover: map['media']['coverImage'][imageQuality.value],
episode: map['episode'],
airingAt: DateTimeExtension.fromSecondsSinceEpoch(map['airingAt']),
entryStatus: ListStatus.from(map['media']['mediaListEntry']?['status']),
streamingServices: streamingServices,
);
}
final int mediaId;
final String title;
final String cover;
final int episode;
final DateTime airingAt;
final ListStatus? entryStatus;
final List streamingServices;
}
typedef StreamingService = ({String url, String site, Color? color});
class CalendarFilter {
const CalendarFilter({required this.date, required this.season, required this.status});
factory CalendarFilter.empty() =>
CalendarFilter(date: DateTime.now(), season: .all, status: .all);
factory CalendarFilter.fromPersistenceMap(Map map) {
final season = CalendarSeasonFilter.values.getOrFirst(map['season']);
final status = CalendarStatusFilter.values.getOrFirst(map['status']);
return CalendarFilter(date: DateTime.now(), season: season, status: status);
}
final DateTime date;
final CalendarSeasonFilter season;
final CalendarStatusFilter status;
CalendarFilter copyWith({
DateTime? date,
CalendarSeasonFilter? season,
CalendarStatusFilter? status,
}) => CalendarFilter(
date: date ?? this.date,
season: season ?? this.season,
status: status ?? this.status,
);
Map toPersistenceMap() => {'season': season.index, 'status': status.index};
}
enum CalendarSeasonFilter {
all('All'),
current('Current'),
previous('Previous'),
other('Other');
const CalendarSeasonFilter(this.label);
final String label;
}
enum CalendarStatusFilter {
all('All'),
watchingAndPlanning('Watching And Planning'),
notInLists('Not In Lists'),
other('Other');
const CalendarStatusFilter(this.label);
final String label;
}
================================================
FILE: lib/feature/calendar/calendar_provider.dart
================================================
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/extension/date_time_extension.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/feature/viewer/repository_provider.dart';
import 'package:otraku/util/paged.dart';
import 'package:otraku/util/graphql.dart';
import 'package:otraku/feature/calendar/calendar_filter_provider.dart';
import 'package:otraku/feature/calendar/calendar_models.dart';
import 'package:otraku/feature/collection/collection_models.dart';
final calendarProvider = AsyncNotifierProvider.autoDispose>(
CalendarNotifier.new,
);
class CalendarNotifier extends AsyncNotifier> {
late CalendarFilter filter;
@override
FutureOr> build() async {
filter = ref.watch(calendarFilterProvider);
return await _fetch(const Paged());
}
Future fetch(bool onAnime) async {
final oldState = state.value ?? const Paged();
if (!oldState.hasNext) return;
state = await AsyncValue.guard(() => _fetch(oldState));
}
Future> _fetch(Paged oldState) async {
final airingFrom = filter.date.copyWith(hour: 0, minute: 0, second: 0).secondsSinceEpoch;
final airingTo = filter.date.copyWith(hour: 23, minute: 59, second: 59).secondsSinceEpoch;
final data = await ref.read(repositoryProvider).request(GqlQuery.calendar, {
'page': oldState.next,
'airingFrom': airingFrom,
'airingTo': airingTo,
});
final imageQuality = ref.read(persistenceProvider).options.imageQuality;
final items = [];
for (final c in data['Page']['airingSchedules']) {
final season = c['media']['season'];
final year = c['media']['seasonYear'];
if (season == null || year == null) continue;
switch (filter.season) {
case .current:
final currSeason = _previousAndCurrentSeason().$2;
if (season != currSeason || year < filter.date.year - 1) continue;
case .previous:
final prevSeason = _previousAndCurrentSeason().$1;
if (season != prevSeason || year < filter.date.year - 1) continue;
case .other:
final (prevSeason, currSeason) = _previousAndCurrentSeason();
if ((season == prevSeason || season == currSeason) && year >= filter.date.year - 1) {
continue;
}
break;
case .all:
break;
}
final status = c['media']['mediaListEntry']?['status'];
switch (filter.status) {
case .notInLists:
if (status != null) continue;
case .watchingAndPlanning:
if (status != ListStatus.current.value && status != ListStatus.planning.value) {
continue;
}
case .other:
if (status == null ||
status == ListStatus.current.value ||
status == ListStatus.planning.value) {
continue;
}
case .all:
break;
}
items.add(CalendarItem(c, imageQuality));
}
return oldState.withNext(items, data['Page']['pageInfo']['hasNextPage'] ?? false);
}
(String, String) _previousAndCurrentSeason() => switch (filter.date.month) {
>= 3 && <= 5 => ('WINTER', 'SPRING'),
>= 6 && <= 8 => ('SPRING', 'SUMMER'),
>= 9 && <= 11 => ('SUMMER', 'FALL'),
_ => ('FALL', 'WINTER'),
};
}
================================================
FILE: lib/feature/calendar/calendar_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/extension/build_context_extension.dart';
import 'package:otraku/extension/card_extension.dart';
import 'package:otraku/extension/date_time_extension.dart';
import 'package:otraku/feature/media/media_route_tile.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/cached_image.dart';
import 'package:otraku/widget/layout/adaptive_scaffold.dart';
import 'package:otraku/widget/layout/hiding_floating_action_button.dart';
import 'package:otraku/widget/layout/navigation_tool.dart';
import 'package:otraku/widget/layout/top_bar.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/widget/paged_view.dart';
import 'package:otraku/widget/text_rail.dart';
import 'package:otraku/feature/calendar/calendar_filter_provider.dart';
import 'package:otraku/feature/calendar/calendar_filter_sheet.dart';
import 'package:otraku/feature/calendar/calendar_models.dart';
import 'package:otraku/feature/calendar/calendar_provider.dart';
class CalendarView extends StatefulWidget {
const CalendarView();
@override
State createState() => _CalendarViewState();
}
class _CalendarViewState extends State {
final _scrollCtrl = ScrollController();
@override
void dispose() {
super.dispose();
_scrollCtrl.dispose();
}
@override
Widget build(BuildContext context) {
final textTheme = TextTheme.of(context);
final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);
final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);
final tileHeight = bodyMediumLineHeight * 2 + labelMediumLineHeight + 55;
final coverWidth = tileHeight / Theming.coverHtoWRatio;
return Consumer(
builder: (context, ref, _) {
final options = ref.watch(persistenceProvider.select((s) => s.options));
final date = ref.watch(calendarFilterProvider.select((s) => s.date));
final today = DateTime.now();
final isBeforeToday =
date.day < today.day && date.month == today.month && date.year == today.year;
return AdaptiveScaffold(
topBar: const TopBar(title: 'Calendar'),
floatingAction: HidingFloatingActionButton(
key: const Key('filter'),
scrollCtrl: _scrollCtrl,
child: FloatingActionButton(
tooltip: 'Filter',
onPressed: () => showCalendarFilterSheet(context, ref),
child: const Icon(Ionicons.funnel_outline),
),
),
bottomBar: BottomBar([
const SizedBox(width: Theming.offset),
SizedBox(
width: 60,
child: isBeforeToday
? null
: IconButton(
icon: const Icon(Icons.arrow_back_ios_rounded),
onPressed: () => _setDate(ref, date.subtract(const Duration(days: 1))),
),
),
Expanded(
child: TextButton(
onPressed: () =>
showDatePicker(
context: context,
initialDate: date,
firstDate: today.add(const Duration(days: -1)),
lastDate: today.add(const Duration(days: 150)),
).then((newDate) {
if (newDate != null && newDate != date) {
_setDate(ref, newDate);
}
}),
child: Text(date.formattedWithWeekDay),
),
),
SizedBox(
width: 60,
child: IconButton(
icon: const Icon(Icons.arrow_forward_ios_rounded),
onPressed: () => _setDate(ref, date.add(const Duration(days: 1))),
),
),
const SizedBox(width: Theming.offset),
]),
child: PagedView(
provider: calendarProvider,
scrollCtrl: _scrollCtrl,
onRefresh: (invalidate) => invalidate(calendarProvider),
onData: (data) => SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, i) =>
_Tile(data.items[i], coverWidth, options.highContrast, options.analogClock),
childCount: data.items.length,
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 1,
mainAxisExtent: tileHeight,
mainAxisSpacing: Theming.offset,
crossAxisSpacing: Theming.offset,
),
),
),
);
},
);
}
void _setDate(WidgetRef ref, DateTime date) {
final filter = ref.read(calendarFilterProvider);
ref.read(calendarFilterProvider.notifier).state = filter.copyWith(date: date);
}
}
class _Tile extends StatelessWidget {
const _Tile(this.item, this.coverWidth, this.highContrast, this.analogClock);
final CalendarItem item;
final double coverWidth;
final bool highContrast;
final bool analogClock;
@override
Widget build(BuildContext context) {
final textRailItems = {
item.airingAt.formattedTime(analogClock): true,
if (item.airingAt.isAfter(DateTime.now()))
'Ep ${item.episode} in ${item.airingAt.timeUntil}': false
else
'Ep ${item.episode}': false,
};
if (item.entryStatus != null) {
textRailItems[item.entryStatus!.label(true)] = true;
}
return CardExtension.highContrast(highContrast)(
child: MediaRouteTile(
id: item.mediaId,
imageUrl: item.cover,
child: Row(
children: [
Hero(
tag: item.mediaId,
child: ClipRRect(
borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),
child: Container(
width: coverWidth,
color: ColorScheme.of(context).surfaceContainerHighest,
child: CachedImage(item.cover),
),
),
),
Expanded(
child: Padding(
padding: const .symmetric(vertical: 5),
child: Column(
crossAxisAlignment: .start,
mainAxisAlignment: .spaceAround,
children: [
Flexible(
child: Padding(
padding: const .symmetric(horizontal: Theming.offset),
child: Text(item.title, overflow: .ellipsis, maxLines: 2),
),
),
Padding(
padding: const .symmetric(horizontal: Theming.offset, vertical: 5),
child: TextRail(
textRailItems,
style: TextTheme.of(context).labelMedium,
maxLines: 1,
),
),
if (item.streamingServices.isNotEmpty)
SizedBox(height: 35, child: _ExternalLinkList(item.streamingServices)),
],
),
),
),
],
),
),
);
}
}
class _ExternalLinkList extends StatelessWidget {
const _ExternalLinkList(this.links);
final List links;
@override
Widget build(BuildContext context) {
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const .only(left: Theming.offset, right: Theming.offset / 2),
itemCount: links.length,
itemBuilder: (context, i) {
return Padding(
padding: const .only(right: Theming.offset / 2),
child: ActionChip(
onPressed: () => SnackBarExtension.launch(context, links[i].url),
label: Text(links[i].site),
avatar: links[i].color != null
? Container(
height: 15,
width: 15,
decoration: BoxDecoration(
borderRadius: Theming.borderRadiusSmall,
color: links[i].color,
),
)
: null,
),
);
},
);
}
}
================================================
FILE: lib/feature/character/character_anime_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:otraku/feature/character/character_model.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/grid/dual_relation_grid.dart';
import 'package:otraku/widget/paged_view.dart';
import 'package:otraku/feature/character/character_provider.dart';
import 'package:otraku/widget/shadowed_overflow_list.dart';
class CharacterAnimeSubview extends StatelessWidget {
const CharacterAnimeSubview({
required this.id,
required this.scrollCtrl,
required this.highContrast,
});
final int id;
final ScrollController scrollCtrl;
final bool highContrast;
@override
Widget build(BuildContext context) {
return PagedView<(CharacterRelatedItem, CharacterRelatedItem?)>(
scrollCtrl: scrollCtrl,
onRefresh: (invalidate) => invalidate(characterMediaProvider(id)),
provider: characterMediaProvider(
id,
).select((s) => s.unwrapPrevious().whenData((data) => data.assembleAnimeWithVoiceActors())),
onData: (data) {
return SliverMainAxisGroup(
slivers: [
_LanguageSelected(id),
DualRelationGrid(
items: data.items,
onTapPrimary: (item) => context.push(Routes.media(item.tileId, item.tileImageUrl)),
onTapSecondary: (item) => context.push(Routes.staff(item.tileId, item.tileImageUrl)),
highContrast: highContrast,
),
],
);
},
);
}
}
class _LanguageSelected extends StatelessWidget {
const _LanguageSelected(this.id);
final int id;
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
final selection = ref.watch(
characterMediaProvider(id).select((s) {
final value = s.value;
if (value == null) return null;
return (value.languageToVoiceActors, value.selectedLanguage);
}),
);
if (selection == null) return const SliverToBoxAdapter();
final languageMappings = selection.$1;
final selectedLanguage = selection.$2;
if (languageMappings.length < 2) return const SliverToBoxAdapter();
return SliverToBoxAdapter(
child: SizedBox(
height: Theming.normalTapTarget,
child: ShadowedOverflowList(
itemCount: languageMappings.length,
itemBuilder: (context, i) => FilterChip(
label: Text(languageMappings[i].language),
selected: i == selectedLanguage,
onSelected: (selected) {
if (!selected) return;
ref.read(characterMediaProvider(id).notifier).changeLanguage(i);
},
),
),
),
);
},
);
}
}
================================================
FILE: lib/feature/character/character_filter_model.dart
================================================
import 'package:otraku/feature/media/media_models.dart';
class CharacterFilter {
const CharacterFilter({this.sort = .trendingDesc, this.inLists});
final MediaSort sort;
final bool? inLists;
CharacterFilter copyWith({MediaSort? sort, (bool?,)? inLists}) => CharacterFilter(
sort: sort ?? this.sort,
inLists: inLists == null ? this.inLists : inLists.$1,
);
}
================================================
FILE: lib/feature/character/character_filter_provider.dart
================================================
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/feature/character/character_filter_model.dart';
final characterFilterProvider = NotifierProvider.autoDispose
.family(CharacterFilterNotifier.new);
class CharacterFilterNotifier extends Notifier {
CharacterFilterNotifier(this.arg);
final int arg;
@override
CharacterFilter build() => const CharacterFilter();
@override
set state(CharacterFilter newState) => super.state = newState;
}
================================================
FILE: lib/feature/character/character_floating_actions.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/feature/character/character_filter_provider.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/widget/input/chip_selector.dart';
import 'package:otraku/feature/media/media_models.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/sheets.dart';
class CharacterMediaFilterButton extends StatelessWidget {
const CharacterMediaFilterButton(this.id, this.ref);
final int id;
final WidgetRef ref;
@override
Widget build(BuildContext context) {
return FloatingActionButton(
tooltip: 'Filter',
child: const Icon(Ionicons.funnel_outline),
onPressed: () {
var filter = ref.read(characterFilterProvider(id));
final onDone = (_) => ref.read(characterFilterProvider(id).notifier).state = filter;
final highContrast = ref.watch(persistenceProvider.select((s) => s.options.highContrast));
showSheet(
context,
SimpleSheet(
initialHeight:
Theming.normalTapTarget * 2.5 + MediaQuery.paddingOf(context).bottom + 40,
builder: (context, scrollCtrl) => ListView(
controller: scrollCtrl,
physics: Theming.bouncyPhysics,
padding: const .symmetric(horizontal: Theming.offset, vertical: 20),
children: [
ChipSelector.ensureSelected(
title: 'Sort',
items: MediaSort.values.map((v) => (v.label, v)).toList(),
value: filter.sort,
onChanged: (v) => filter = filter.copyWith(sort: v),
highContrast: highContrast,
),
ChipSelector(
title: 'List Presence',
items: const [('In Lists', true), ('Not in Lists', false)],
value: filter.inLists,
onChanged: (v) => filter = filter.copyWith(inLists: (v,)),
highContrast: highContrast,
),
],
),
),
).then(onDone);
},
);
}
}
================================================
FILE: lib/feature/character/character_header.dart
================================================
import 'package:flutter/material.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/feature/character/character_model.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/layout/content_header.dart';
import 'package:otraku/widget/table_list.dart';
class CharacterHeader extends StatelessWidget {
const CharacterHeader.withTabBar({
required this.id,
required this.imageUrl,
required this.character,
required TabController this.tabCtrl,
required void Function() this.scrollToTop,
required this.toggleFavorite,
required this.highContrast,
});
const CharacterHeader.withoutTabBar({
required this.id,
required this.imageUrl,
required this.character,
required this.toggleFavorite,
required this.highContrast,
}) : tabCtrl = null,
scrollToTop = null;
final int id;
final String? imageUrl;
final Character? character;
final TabController? tabCtrl;
final void Function()? scrollToTop;
final Future Function() toggleFavorite;
final bool highContrast;
@override
Widget build(BuildContext context) {
return ContentHeader(
imageUrl: imageUrl ?? character?.imageUrl,
imageHeightToWidthRatio: Theming.coverHtoWRatio,
imageHeroTag: id,
siteUrl: character?.siteUrl,
title: character?.preferredName,
details: character != null
? [
TableList([
('Favorites', character!.favorites.toString()),
if (character!.gender != null) ('Gender', character!.gender!),
], highContrast: highContrast),
]
: const [],
tabBarConfig: tabCtrl != null && scrollToTop != null
? (tabCtrl: tabCtrl!, scrollToTop: scrollToTop!, tabs: tabsWithOverview)
: null,
trailingTopButtons: [if (character != null) _FavoriteButton(character!, toggleFavorite)],
);
}
static const tabsWithoutOverview = [Tab(text: 'Anime'), Tab(text: 'Manga')];
static const tabsWithOverview = [Tab(text: 'Overview'), ...tabsWithoutOverview];
}
class _FavoriteButton extends StatefulWidget {
const _FavoriteButton(this.character, this.toggleFavorite);
final Character character;
final Future Function() toggleFavorite;
@override
State<_FavoriteButton> createState() => __FavoriteButtonState();
}
class __FavoriteButtonState extends State<_FavoriteButton> {
@override
Widget build(BuildContext context) {
final character = widget.character;
return IconButton(
tooltip: character.isFavorite ? 'Unfavourite' : 'Favourite',
icon: character.isFavorite ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border),
onPressed: () async {
setState(() => character.isFavorite = !character.isFavorite);
final err = await widget.toggleFavorite();
if (err == null) return;
setState(() => character.isFavorite = !character.isFavorite);
if (context.mounted) SnackBarExtension.show(context, err.toString());
},
);
}
}
================================================
FILE: lib/feature/character/character_item_grid.dart
================================================
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:otraku/extension/build_context_extension.dart';
import 'package:otraku/extension/card_extension.dart';
import 'package:otraku/feature/character/character_item_model.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/cached_image.dart';
import 'package:otraku/widget/grid/sliver_grid_delegates.dart';
class CharacterItemGrid extends StatelessWidget {
const CharacterItemGrid(this.items, {required this.highContrast});
final List items;
final bool highContrast;
@override
Widget build(BuildContext context) {
final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);
final textHeight = lineHeight * 2 + 10;
return SliverGrid(
gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight(
minWidth: 100,
extraHeight: textHeight,
rawHWRatio: Theming.coverHtoWRatio,
),
delegate: SliverChildBuilderDelegate(
(_, i) => _Tile(items[i], highContrast, textHeight),
childCount: items.length,
),
);
}
}
class _Tile extends StatelessWidget {
const _Tile(this.item, this.highContrast, this.textHeight);
final CharacterItem item;
final bool highContrast;
final double textHeight;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: Theming.borderRadiusSmall,
onTap: () => context.push(Routes.character(item.id, item.imageUrl)),
child: CardExtension.highContrast(highContrast)(
child: Column(
crossAxisAlignment: .stretch,
children: [
Expanded(
child: Hero(
tag: item.id,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall),
child: CachedImage(item.imageUrl),
),
),
),
SizedBox(
height: textHeight,
child: Padding(
padding: const .all(5),
child: Text(item.name, maxLines: 2, overflow: .ellipsis),
),
),
],
),
),
);
}
}
================================================
FILE: lib/feature/character/character_item_model.dart
================================================
class CharacterItem {
const CharacterItem._({required this.id, required this.name, required this.imageUrl});
factory CharacterItem(Map map) => CharacterItem._(
id: map['id'],
name: map['name']['userPreferred'],
imageUrl: map['image']['large'],
);
final int id;
final String name;
final String imageUrl;
}
================================================
FILE: lib/feature/character/character_manga_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:otraku/feature/character/character_model.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/widget/grid/mono_relation_grid.dart';
import 'package:otraku/widget/paged_view.dart';
import 'package:otraku/feature/character/character_provider.dart';
class CharacterMangaSubview extends StatelessWidget {
const CharacterMangaSubview({
required this.id,
required this.scrollCtrl,
required this.highContrast,
});
final int id;
final ScrollController scrollCtrl;
final bool highContrast;
@override
Widget build(BuildContext context) {
return PagedView(
scrollCtrl: scrollCtrl,
onRefresh: (invalidate) => invalidate(characterMediaProvider(id)),
provider: characterMediaProvider(
id,
).select((s) => s.unwrapPrevious().whenData((data) => data.manga)),
onData: (data) => MonoRelationGrid(
items: data.items,
onTap: (item) => context.push(Routes.media(item.tileId, item.tileImageUrl)),
highContrast: highContrast,
),
);
}
}
================================================
FILE: lib/feature/character/character_model.dart
================================================
import 'package:otraku/extension/string_extension.dart';
import 'package:otraku/feature/viewer/persistence_model.dart';
import 'package:otraku/util/paged.dart';
import 'package:otraku/util/markdown.dart';
import 'package:otraku/feature/settings/settings_model.dart';
import 'package:otraku/util/tile_modelable.dart';
class Character {
Character._({
required this.id,
required this.preferredName,
required this.fullName,
required this.nativeName,
required this.altNames,
required this.altNamesSpoilers,
required this.imageUrl,
required this.description,
required this.dateOfBirth,
required this.bloodType,
required this.gender,
required this.age,
required this.siteUrl,
required this.favorites,
required this.isFavorite,
});
factory Character(Map map, PersonNaming personNaming) {
final names = map['name'];
final nameSegments = [
names['first'],
if (names['middle']?.isNotEmpty ?? false) names['middle'],
if (names['last']?.isNotEmpty ?? false) names['last'],
];
final fullName = personNaming == .romajiWestern
? nameSegments.join(' ')
: nameSegments.reversed.toList().join(' ');
final nativeName = names['native'];
final altNames = List.from(names['alternative'] ?? []);
final altNamesSpoilers = List.from(names['alternativeSpoiler'] ?? [], growable: false);
final preferredName = nativeName != null
? personNaming != .native
? fullName
: nativeName
: fullName;
return Character._(
id: map['id'],
preferredName: preferredName,
fullName: fullName,
nativeName: nativeName,
altNames: altNames,
altNamesSpoilers: altNamesSpoilers,
description: parseMarkdown(map['description'] ?? ''),
imageUrl: map['image']['large'],
dateOfBirth: StringExtension.fromFuzzyDate(map['dateOfBirth']),
bloodType: map['bloodType'],
gender: map['gender'],
age: map['age'],
siteUrl: map['siteUrl'],
favorites: map['favourites'] ?? 0,
isFavorite: map['isFavourite'] ?? false,
);
}
final int id;
final String preferredName;
final String fullName;
final String? nativeName;
final List altNames;
final List altNamesSpoilers;
final String imageUrl;
final String description;
final String? dateOfBirth;
final String? bloodType;
final String? gender;
final String? age;
final String? siteUrl;
final int favorites;
bool isFavorite;
}
class CharacterMedia {
const CharacterMedia({
this.anime = const Paged(),
this.manga = const Paged(),
this.languageToVoiceActors = const [],
this.selectedLanguage = 0,
});
final Paged anime;
final Paged manga;
/// For each language, a list of voice actors
/// is mapped to the corresponding media's id.
final List languageToVoiceActors;
final int selectedLanguage;
/// Returns the media, in which the character has participated,
/// along with the voice actors, corresponding to the current [language].
/// If there are multiple actors, the given media is repeated for each actor.
Paged<(CharacterRelatedItem, CharacterRelatedItem?)> assembleAnimeWithVoiceActors() {
if (languageToVoiceActors.isEmpty) {
return Paged(
items: anime.items.map((a) => (a, null)).toList(),
hasNext: anime.hasNext,
next: anime.next,
);
}
final actorsPerMedia = languageToVoiceActors[selectedLanguage];
final animeAndVoiceActors = <(CharacterRelatedItem, CharacterRelatedItem?)>[];
for (final a in anime.items) {
final actors = actorsPerMedia.voiceActors[a.id];
if (actors == null || actors.isEmpty) {
animeAndVoiceActors.add((a, null));
continue;
}
for (final va in actors) {
animeAndVoiceActors.add((a, va));
}
}
return Paged(items: animeAndVoiceActors, hasNext: anime.hasNext, next: anime.next);
}
}
class CharacterRelatedItem implements TileModelable {
const CharacterRelatedItem._({
required this.id,
required this.name,
required this.imageUrl,
required this.role,
});
factory CharacterRelatedItem.media(
Map map,
String? role,
ImageQuality imageQuality,
) => CharacterRelatedItem._(
id: map['id'],
name: map['title']['userPreferred'],
imageUrl: map['coverImage'][imageQuality.value],
role: role,
);
factory CharacterRelatedItem.staff(Map map, String? role) =>
CharacterRelatedItem._(
id: map['id'],
name: map['name']['userPreferred'],
imageUrl: map['image']['large'],
role: role,
);
final int id;
final String name;
final String imageUrl;
final String? role;
@override
int get tileId => id;
@override
String get tileTitle => name;
@override
String? get tileSubtitle => role;
@override
String get tileImageUrl => imageUrl;
}
typedef CharacterLanguageMapping = ({
String language,
Map> voiceActors,
});
================================================
FILE: lib/feature/character/character_overview_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/feature/character/character_model.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/table_list.dart';
import 'package:otraku/widget/html_content.dart';
import 'package:otraku/widget/loaders.dart';
class CharacterOverviewSubview extends StatelessWidget {
const CharacterOverviewSubview.asFragment({
required this.character,
required this.invalidate,
required this.highContrast,
required ScrollController this.scrollCtrl,
}) : header = null;
const CharacterOverviewSubview.withHeader({
required this.character,
required this.invalidate,
required this.highContrast,
required Widget this.header,
}) : scrollCtrl = null;
final Character character;
final void Function() invalidate;
final Widget? header;
final ScrollController? scrollCtrl;
final bool highContrast;
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final refreshControl = SliverRefreshControl(onRefresh: invalidate);
return CustomScrollView(
physics: Theming.bouncyPhysics,
controller: scrollCtrl,
slivers: [
if (header != null) ...[
header!,
MediaQuery(
data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)),
child: refreshControl,
),
] else
refreshControl,
SliverPadding(
padding: const .symmetric(horizontal: Theming.offset),
sliver: SliverMainAxisGroup(
slivers: [
_NameTable(character, highContrast),
const SliverToBoxAdapter(child: SizedBox(height: Theming.offset)),
SliverTableList([
if (character.dateOfBirth != null) ('Birth', character.dateOfBirth!),
if (character.age != null) ('Age', character.age!),
if (character.bloodType != null) ('Blood Type', character.bloodType!),
], highContrast: highContrast),
if (character.description.isNotEmpty) ...[
const SliverToBoxAdapter(child: SizedBox(height: 15)),
HtmlContent(character.description, renderMode: RenderMode.sliverList),
],
],
),
),
const SliverFooter(),
],
);
}
}
class _NameTable extends StatefulWidget {
const _NameTable(this.character, this.highContrast);
final Character character;
final bool highContrast;
@override
State<_NameTable> createState() => __NameTableState();
}
class __NameTableState extends State<_NameTable> {
var _showSpoilers = false;
@override
Widget build(BuildContext context) {
return SliverMainAxisGroup(
slivers: [
SliverTableList([
('Full', widget.character.fullName),
if (widget.character.nativeName != null) ('Native', widget.character.nativeName!),
...widget.character.altNames.map((s) => ('Alternative', s)),
if (_showSpoilers)
...widget.character.altNamesSpoilers.map((s) => ('Alternative Spoiler', s)),
], highContrast: widget.highContrast),
if (widget.character.altNamesSpoilers.isNotEmpty && !_showSpoilers)
SliverToBoxAdapter(
child: TextButton.icon(
label: const Text('Show Spoilers'),
icon: const Icon(Ionicons.eye_outline),
onPressed: () => setState(() => _showSpoilers = true),
),
),
],
);
}
}
================================================
FILE: lib/feature/character/character_provider.dart
================================================
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/extension/future_extension.dart';
import 'package:otraku/extension/iterable_extension.dart';
import 'package:otraku/extension/string_extension.dart';
import 'package:otraku/feature/character/character_filter_model.dart';
import 'package:otraku/feature/character/character_filter_provider.dart';
import 'package:otraku/feature/character/character_model.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/feature/viewer/repository_provider.dart';
import 'package:otraku/util/graphql.dart';
import 'package:otraku/feature/settings/settings_provider.dart';
final characterProvider = AsyncNotifierProvider.autoDispose
.family(CharacterNotifier.new);
final characterMediaProvider = AsyncNotifierProvider.autoDispose
.family(CharacterMediaNotifier.new);
class CharacterNotifier extends AsyncNotifier {
CharacterNotifier(this.arg);
final int arg;
@override
FutureOr build() async {
final data = await ref.read(repositoryProvider).request(GqlQuery.character, {
'id': arg,
'withInfo': true,
});
final personNaming = await ref.watch(settingsProvider.selectAsync((data) => data.personNaming));
return Character(data['Character'], personNaming);
}
Future toggleFavorite() {
return ref.read(repositoryProvider).request(GqlMutation.toggleFavorite, {
'character': arg,
}).getErrorOrNull();
}
}
class CharacterMediaNotifier extends AsyncNotifier {
CharacterMediaNotifier(this.arg);
final int arg;
late CharacterFilter filter;
@override
FutureOr build() async {
filter = ref.watch(characterFilterProvider(arg));
return await _fetch(const CharacterMedia(), null);
}
Future fetch(bool onAnime) async {
final oldState = state.value ?? const CharacterMedia();
if (onAnime) {
if (!oldState.anime.hasNext) return;
} else {
if (!oldState.manga.hasNext) return;
}
state = await AsyncValue.guard(() => _fetch(oldState, onAnime));
}
Future _fetch(CharacterMedia oldState, bool? onAnime) async {
final variables = {'id': arg, 'onList': filter.inLists, 'sort': filter.sort.value};
if (onAnime == null) {
variables['withAnime'] = true;
variables['withManga'] = true;
} else if (onAnime) {
variables['withAnime'] = true;
variables['page'] = oldState.anime.next;
} else if (!onAnime) {
variables['withManga'] = true;
variables['page'] = oldState.manga.next;
}
var data = await ref.read(repositoryProvider).request(GqlQuery.character, variables);
data = data['Character'];
final imageQuality = ref.read(persistenceProvider).options.imageQuality;
var anime = oldState.anime;
var manga = oldState.manga;
var languageToVoiceActors = [...oldState.languageToVoiceActors];
var selectedLanguage = oldState.selectedLanguage;
if (onAnime == null || onAnime) {
final map = data['anime'];
final items = [];
for (final a in map['edges']) {
items.add(
CharacterRelatedItem.media(
a['node'],
StringExtension.tryNoScreamingSnakeCase(a['characterRole']),
imageQuality,
),
);
if (a['voiceActors'] != null) {
for (final va in a['voiceActors']) {
final l = StringExtension.tryNoScreamingSnakeCase(va['languageV2']);
if (l == null) continue;
var languageMapping = languageToVoiceActors.firstWhereOrNull((lm) => lm.language == l);
if (languageMapping == null) {
languageMapping = (language: l, voiceActors: {});
languageToVoiceActors.add(languageMapping);
}
final mediaVoiceActors = languageMapping.voiceActors.putIfAbsent(
items.last.id,
() => [],
);
mediaVoiceActors.add(CharacterRelatedItem.staff(va, l));
}
}
languageToVoiceActors.sort((a, b) {
if (a.language == 'Japanese') return -1;
if (b.language == 'Japanese') return 1;
return a.language.compareTo(b.language);
});
}
anime = anime.withNext(items, map['pageInfo']['hasNextPage'] ?? false);
}
if (onAnime == null || !onAnime) {
final map = data['manga'];
final items = [];
for (final m in map['edges']) {
items.add(
CharacterRelatedItem.media(
m['node'],
StringExtension.tryNoScreamingSnakeCase(m['characterRole']),
imageQuality,
),
);
}
manga = manga.withNext(items, map['pageInfo']['hasNextPage'] ?? false);
}
return CharacterMedia(
anime: anime,
manga: manga,
languageToVoiceActors: languageToVoiceActors,
selectedLanguage: selectedLanguage,
);
}
void changeLanguage(int selectedLanguage) => state.whenData((data) {
if (selectedLanguage >= data.languageToVoiceActors.length) return;
state = AsyncValue.data(
CharacterMedia(
anime: data.anime,
manga: data.manga,
languageToVoiceActors: data.languageToVoiceActors,
selectedLanguage: selectedLanguage,
),
);
});
}
================================================
FILE: lib/feature/character/character_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/extension/scroll_controller_extension.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/feature/character/character_header.dart';
import 'package:otraku/feature/character/character_model.dart';
import 'package:otraku/feature/character/character_floating_actions.dart';
import 'package:otraku/feature/character/character_anime_view.dart';
import 'package:otraku/feature/character/character_manga_view.dart';
import 'package:otraku/feature/character/character_provider.dart';
import 'package:otraku/feature/character/character_overview_view.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/util/paged_controller.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/layout/adaptive_scaffold.dart';
import 'package:otraku/widget/layout/constrained_view.dart';
import 'package:otraku/widget/layout/hiding_floating_action_button.dart';
import 'package:otraku/widget/layout/dual_pane_with_tab_bar.dart';
import 'package:otraku/widget/loaders.dart';
class CharacterView extends ConsumerStatefulWidget {
const CharacterView(this.id, this.imageUrl);
final int id;
final String? imageUrl;
@override
ConsumerState createState() => _CharacterViewState();
}
class _CharacterViewState extends ConsumerState {
final _scrollCtrl = PagedController(loadMore: () {});
@override
void dispose() {
_scrollCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
ref.listen(characterProvider(widget.id), (_, s) {
if (s.hasError) {
SnackBarExtension.show(context, 'Failed to load character: ${s.error}');
}
});
final character = ref.watch(characterProvider(widget.id));
final options = ref.watch(persistenceProvider.select((s) => s.options));
final toggleFavorite = () => ref.read(characterProvider(widget.id).notifier).toggleFavorite();
return AdaptiveScaffold(
floatingAction: HidingFloatingActionButton(
key: const Key('filter'),
scrollCtrl: _scrollCtrl,
child: CharacterMediaFilterButton(widget.id, ref),
),
child: switch (Theming.of(context).formFactor) {
.phone => _CompactView(
id: widget.id,
imageUrl: widget.imageUrl,
ref: ref,
highContrast: options.highContrast,
character: character,
scrollCtrl: _scrollCtrl,
toggleFavorite: toggleFavorite,
),
.tablet => _LargeView(
id: widget.id,
imageUrl: widget.imageUrl,
ref: ref,
highContrast: options.highContrast,
character: character,
scrollCtrl: _scrollCtrl,
toggleFavorite: toggleFavorite,
),
},
);
}
}
class _CompactView extends StatefulWidget {
const _CompactView({
required this.id,
required this.imageUrl,
required this.ref,
required this.highContrast,
required this.character,
required this.scrollCtrl,
required this.toggleFavorite,
});
final int id;
final String? imageUrl;
final WidgetRef ref;
final bool highContrast;
final AsyncValue character;
final PagedController scrollCtrl;
final Future Function() toggleFavorite;
@override
State<_CompactView> createState() => _CompactViewState();
}
class _CompactViewState extends State<_CompactView> with SingleTickerProviderStateMixin {
late final _tabCtrl = TabController(length: CharacterHeader.tabsWithOverview.length, vsync: this);
@override
void initState() {
super.initState();
widget.scrollCtrl.loadMore = () {
if (_tabCtrl.index > 0) {
widget.ref.read(characterMediaProvider(widget.id).notifier).fetch(_tabCtrl.index == 1);
}
};
}
@override
void dispose() {
_tabCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final header = CharacterHeader.withTabBar(
id: widget.id,
imageUrl: widget.imageUrl,
character: widget.character.value,
tabCtrl: _tabCtrl,
scrollToTop: widget.scrollCtrl.scrollToTop,
toggleFavorite: widget.toggleFavorite,
highContrast: widget.highContrast,
);
return NestedScrollView(
controller: widget.scrollCtrl,
headerSliverBuilder: (context, _) => [header],
body: MediaQuery(
data: mediaQuery.copyWith(padding: mediaQuery.padding.copyWith(top: 0)),
child: widget.character.unwrapPrevious().when(
loading: () => const Center(child: Loader()),
error: (_, _) => const Center(child: Text('Failed to load character')),
data: (data) => _CharacterTabs.withOverview(
id: widget.id,
character: data,
tabCtrl: _tabCtrl,
highContrast: widget.highContrast,
),
),
),
);
}
}
class _LargeView extends StatefulWidget {
const _LargeView({
required this.id,
required this.imageUrl,
required this.ref,
required this.highContrast,
required this.character,
required this.scrollCtrl,
required this.toggleFavorite,
});
final int id;
final String? imageUrl;
final WidgetRef ref;
final bool highContrast;
final AsyncValue character;
final PagedController scrollCtrl;
final Future Function() toggleFavorite;
@override
State<_LargeView> createState() => _LargeViewState();
}
class _LargeViewState extends State<_LargeView> with SingleTickerProviderStateMixin {
late final _tabCtrl = TabController(
length: CharacterHeader.tabsWithoutOverview.length,
vsync: this,
);
@override
void initState() {
super.initState();
widget.scrollCtrl.loadMore = () {
widget.ref.read(characterMediaProvider(widget.id).notifier).fetch(_tabCtrl.index == 0);
};
}
@override
void dispose() {
_tabCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final header = CharacterHeader.withoutTabBar(
id: widget.id,
imageUrl: widget.imageUrl,
character: widget.character.value,
toggleFavorite: widget.toggleFavorite,
highContrast: widget.highContrast,
);
return DualPaneWithTabBar(
tabCtrl: _tabCtrl,
scrollToTop: widget.scrollCtrl.scrollToTop,
tabs: CharacterHeader.tabsWithoutOverview,
leftPane: widget.character.unwrapPrevious().when(
loading: () => CustomScrollView(
physics: Theming.bouncyPhysics,
slivers: [
header,
const SliverFillRemaining(child: Center(child: Loader())),
],
),
error: (_, _) => CustomScrollView(
physics: Theming.bouncyPhysics,
slivers: [
header,
const SliverFillRemaining(child: Center(child: Text('Failed to load character'))),
],
),
data: (data) => CharacterOverviewSubview.withHeader(
character: data,
header: header,
highContrast: widget.highContrast,
invalidate: () => widget.ref.invalidate(characterProvider(widget.id)),
),
),
rightPane: widget.character.unwrapPrevious().maybeWhen(
data: (data) => _CharacterTabs.withoutOverview(
id: widget.id,
character: data,
tabCtrl: _tabCtrl,
scrollCtrl: widget.scrollCtrl,
highContrast: widget.highContrast,
),
orElse: () => const SizedBox(),
),
);
}
}
class _CharacterTabs extends ConsumerStatefulWidget {
const _CharacterTabs.withOverview({
required this.id,
required this.character,
required this.tabCtrl,
required this.highContrast,
}) : withOverview = true,
scrollCtrl = null;
const _CharacterTabs.withoutOverview({
required this.id,
required this.character,
required this.tabCtrl,
required this.highContrast,
required ScrollController this.scrollCtrl,
}) : withOverview = false;
final int id;
final Character character;
final TabController tabCtrl;
final ScrollController? scrollCtrl;
final bool highContrast;
final bool withOverview;
@override
ConsumerState<_CharacterTabs> createState() => __CharacterViewContentState();
}
class __CharacterViewContentState extends ConsumerState<_CharacterTabs> {
late final ScrollController _scrollCtrl;
double _lastMaxExtent = 0;
@override
void initState() {
super.initState();
_scrollCtrl =
widget.scrollCtrl ??
context.findAncestorStateOfType()!.innerController;
_scrollCtrl.addListener(_scrollListener);
widget.tabCtrl.addListener(_tabListener);
}
@override
void dispose() {
_scrollCtrl.removeListener(_scrollListener);
widget.tabCtrl.removeListener(_tabListener);
super.dispose();
}
void _tabListener() {
_lastMaxExtent = 0;
// This is a workaround for an issue with [NestedScrollView].
// If you switch to a tab with pagination, where the content
// doesn't fill the view, the scroll controller has it's maximum
// extent set to 0 and the loading of a next page of items is not triggered.
// This is why we need to manually load the second page.
if (!widget.tabCtrl.indexIsChanging && _scrollCtrl.hasClients) {
final pos = _scrollCtrl.positions.last;
if (pos.minScrollExtent == pos.maxScrollExtent) _loadNextPage();
}
}
void _scrollListener() {
final pos = _scrollCtrl.positions.last;
if (pos.pixels < pos.maxScrollExtent - 100) return;
if (_lastMaxExtent == pos.maxScrollExtent) return;
_lastMaxExtent = pos.maxScrollExtent;
_loadNextPage();
}
void _loadNextPage() {
final index = widget.withOverview ? widget.tabCtrl.index : widget.tabCtrl.index + 1;
if (index > 0) {
ref.read(characterMediaProvider(widget.id).notifier).fetch(index == 1);
}
}
@override
Widget build(BuildContext context) {
ref.watch(characterMediaProvider(widget.id).select((_) => null));
final options = ref.watch(persistenceProvider.select((s) => s.options));
return TabBarView(
controller: widget.tabCtrl,
children: [
if (widget.withOverview)
ConstrainedView(
padded: false,
child: CharacterOverviewSubview.asFragment(
character: widget.character,
scrollCtrl: _scrollCtrl,
invalidate: () => ref.invalidate(characterProvider(widget.id)),
highContrast: widget.highContrast,
),
),
CharacterAnimeSubview(
id: widget.id,
scrollCtrl: _scrollCtrl,
highContrast: options.highContrast,
),
CharacterMangaSubview(
id: widget.id,
scrollCtrl: _scrollCtrl,
highContrast: options.highContrast,
),
],
);
}
}
================================================
FILE: lib/feature/collection/collection_entries_provider.dart
================================================
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/feature/collection/collection_filter_model.dart';
import 'package:otraku/feature/collection/collection_filter_provider.dart';
import 'package:otraku/feature/collection/collection_models.dart';
import 'package:otraku/feature/collection/collection_provider.dart';
import 'package:otraku/feature/tag/tag_model.dart';
import 'package:otraku/feature/tag/tag_provider.dart';
final collectionEntriesProvider = Provider.autoDispose.family, CollectionTag>((
ref,
CollectionTag tag,
) {
final filter = ref.watch(collectionFilterProvider(tag));
final mediaFilter = filter.mediaFilter;
final search = filter.search.toLowerCase();
ref
.watch(collectionProvider(tag).notifier)
.ensureSorted(mediaFilter.sort, mediaFilter.previewSort);
final lists = switch (ref.watch(collectionProvider(tag)).unwrapPrevious().value) {
PreviewCollection c => [c.list],
FullCollection c => c.index < 0 ? c.lists : [c.lists[c.index]],
null => const [],
};
final tags = ref.watch(tagsProvider).value;
return _filter(lists, mediaFilter, search, tags);
});
List _filter(
List lists,
CollectionMediaFilter mediaFilter,
String search,
TagCollection? tags,
) {
final filteredLists = [];
final releaseStartFrom = mediaFilter.startYearFrom != null
? DateTime(mediaFilter.startYearFrom!)
: DateTime(1920);
final releaseStartTo = mediaFilter.startYearTo != null
? DateTime(mediaFilter.startYearTo! + 1)
: DateTime.now().add(const Duration(days: 900));
var tagIdIn = const [];
var tagIdNotIn = const [];
if (tags != null) {
final tagFinder = (String name) => tags.ids[tags.indexByName[name] ?? 0];
tagIdIn = mediaFilter.tagIn.map(tagFinder).toList();
tagIdNotIn = mediaFilter.tagNotIn.map(tagFinder).toList();
}
for (final l in lists) {
final entries = [];
for (final entry in l.entries) {
if (search.isNotEmpty) {
bool contains = false;
for (final title in entry.titles) {
if (title.toLowerCase().contains(search)) {
contains = true;
break;
}
}
if (!contains && entry.notes.toLowerCase().contains(search)) {
contains = true;
}
if (!contains) continue;
}
if (mediaFilter.country != null && entry.country != mediaFilter.country!.code) {
continue;
}
if (mediaFilter.formats.isNotEmpty && !mediaFilter.formats.contains(entry.format)) {
continue;
}
if (mediaFilter.statuses.isNotEmpty && !mediaFilter.statuses.contains(entry.releaseStatus)) {
continue;
}
if (entry.releaseStart != null) {
if (releaseStartFrom.isAfter(entry.releaseStart!)) continue;
if (releaseStartTo.isBefore(entry.releaseStart!)) continue;
}
if (mediaFilter.genreIn.isNotEmpty) {
bool isIn = true;
for (final genre in mediaFilter.genreIn) {
if (!entry.genres.contains(genre)) {
isIn = false;
break;
}
}
if (!isIn) continue;
}
if (mediaFilter.genreNotIn.isNotEmpty) {
bool isIn = false;
for (final genre in mediaFilter.genreNotIn) {
if (entry.genres.contains(genre)) {
isIn = true;
break;
}
}
if (isIn) continue;
}
if (tagIdIn.isNotEmpty) {
bool isIn = true;
for (final tagId in tagIdIn) {
if (!entry.tagIds.contains(tagId)) {
isIn = false;
break;
}
}
if (!isIn) continue;
}
if (tagIdNotIn.isNotEmpty) {
bool isIn = false;
for (final tagId in tagIdNotIn) {
if (entry.tagIds.contains(tagId)) {
isIn = true;
break;
}
}
if (isIn) continue;
}
if (mediaFilter.isPrivate != null && entry.isPrivate != mediaFilter.isPrivate) {
continue;
}
if (mediaFilter.hasNotes != null && entry.notes.isNotEmpty != mediaFilter.hasNotes) {
continue;
}
entries.add(entry);
}
if (entries.isNotEmpty) {
filteredLists.add(l.copyWithEntries(entries));
}
}
return filteredLists;
}
================================================
FILE: lib/feature/collection/collection_filter_model.dart
================================================
import 'package:otraku/extension/enum_extension.dart';
import 'package:otraku/feature/media/media_models.dart';
class CollectionFilter {
const CollectionFilter._({required this.search, required this.mediaFilter});
CollectionFilter(this.mediaFilter) : search = '';
final String search;
final CollectionMediaFilter mediaFilter;
CollectionFilter copyWith({String? search, CollectionMediaFilter? mediaFilter}) =>
CollectionFilter._(
search: search ?? this.search,
mediaFilter: mediaFilter ?? this.mediaFilter,
);
}
class CollectionMediaFilter {
CollectionMediaFilter() : sort = .title, previewSort = .title;
factory CollectionMediaFilter.fromPersistenceMap(Map map) {
final sort = EntrySort.values.getOrFirst(map['sort']);
final previewSort = EntrySort.values.getOrFirst(map['previewSort']);
final filter = CollectionMediaFilter()
..sort = sort
..previewSort = previewSort
..startYearFrom = map['startYearFrom']
..startYearTo = map['startYearTo']
..country = OriginCountry.values.getOrNull(map['country'])
..isPrivate = map['isPrivate']
..hasNotes = map['hasNotes'];
for (final e in map['statuses'] ?? const []) {
final status = ReleaseStatus.values.getOrNull(e);
if (status != null) {
filter.statuses.add(status);
}
}
for (final e in map['formats'] ?? const []) {
final format = MediaFormat.values.getOrNull(e);
if (format != null) {
filter.formats.add(format);
}
}
filter.genreIn.addAll(map['genreIn'] ?? const []);
filter.genreNotIn.addAll(map['genreNotIn'] ?? const []);
filter.tagIn.addAll(map['tagIn'] ?? const []);
filter.tagNotIn.addAll(map['tagNotIn'] ?? const []);
return filter;
}
final statuses = [];
final formats = [];
final genreIn = [];
final genreNotIn = [];
final tagIn = [];
final tagNotIn = [];
EntrySort sort;
EntrySort previewSort;
int? startYearFrom;
int? startYearTo;
OriginCountry? country;
bool? isPrivate;
bool? hasNotes;
bool get isActive =>
statuses.isNotEmpty ||
formats.isNotEmpty ||
genreIn.isNotEmpty ||
genreNotIn.isNotEmpty ||
tagIn.isNotEmpty ||
tagNotIn.isNotEmpty ||
startYearFrom != null ||
startYearTo != null ||
country != null ||
isPrivate != null ||
hasNotes != null;
CollectionMediaFilter copy() => CollectionMediaFilter()
..sort = sort
..previewSort = previewSort
..statuses.addAll(statuses)
..formats.addAll(formats)
..genreIn.addAll(genreIn)
..genreNotIn.addAll(genreNotIn)
..tagIn.addAll(tagIn)
..tagNotIn.addAll(tagNotIn)
..startYearFrom = startYearFrom
..startYearTo = startYearTo
..country = country
..isPrivate = isPrivate
..hasNotes = hasNotes;
Map toPersistenceMap() => {
'statuses': statuses.map((e) => e.index).toList(),
'formats': formats.map((e) => e.index).toList(),
'genreIn': genreIn,
'genreNotIn': genreNotIn,
'tagIn': tagIn,
'tagNotIn': tagNotIn,
'sort': sort.index,
'previewSort': previewSort.index,
'startYearFrom': startYearFrom,
'startYearTo': startYearTo,
'country': country?.index,
'isPrivate': isPrivate,
'hasNotes': hasNotes,
};
}
================================================
FILE: lib/feature/collection/collection_filter_provider.dart
================================================
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/feature/collection/collection_filter_model.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/feature/collection/collection_models.dart';
final collectionFilterProvider = NotifierProvider.autoDispose
.family(
CollectionFilterNotifier.new,
);
class CollectionFilterNotifier extends Notifier {
CollectionFilterNotifier(this.arg);
final CollectionTag arg;
@override
CollectionFilter build() {
final mediaFilter = ref.watch(
persistenceProvider.select(
(s) => arg.ofAnime ? s.animeCollectionMediaFilter : s.mangaCollectionMediaFilter,
),
);
return CollectionFilter(mediaFilter.copy());
}
CollectionFilter update(CollectionFilter Function(CollectionFilter) callback) =>
state = callback(state);
}
================================================
FILE: lib/feature/collection/collection_filter_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/feature/collection/collection_filter_model.dart';
import 'package:otraku/feature/collection/collection_models.dart';
import 'package:otraku/widget/dialogs.dart';
import 'package:otraku/widget/input/chip_selector.dart';
import 'package:otraku/feature/tag/tag_picker.dart';
import 'package:otraku/widget/input/year_range_picker.dart';
import 'package:otraku/feature/media/media_models.dart';
import 'package:otraku/feature/tag/tag_provider.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/layout/navigation_tool.dart';
import 'package:otraku/widget/loaders.dart';
import 'package:otraku/widget/sheets.dart';
class CollectionFilterView extends ConsumerStatefulWidget {
const CollectionFilterView({required this.tag, required this.filter, required this.onChanged});
final CollectionTag tag;
final CollectionMediaFilter filter;
final void Function(CollectionMediaFilter) onChanged;
@override
ConsumerState createState() => _FilterCollectionViewState();
}
class _FilterCollectionViewState extends ConsumerState {
late final _filter = widget.filter.copy();
@override
Widget build(BuildContext context) {
final options = ref.watch(persistenceProvider.select((s) => s.options));
final ofViewer = ref.watch(viewerIdProvider) == widget.tag.userId;
final applyButton = BottomBarButton(
text: 'Apply',
icon: Icons.done_rounded,
onTap: () {
widget.onChanged(_filter);
Navigator.pop(context);
},
);
final revertToDefaultButton = BottomBarButton(
text: 'Reset',
icon: Icons.restore_rounded,
foregroundColor: ColorScheme.of(context).secondary,
onTap: () {
final persistence = ref.read(persistenceProvider);
if (widget.tag.ofAnime) {
widget.onChanged(persistence.animeCollectionMediaFilter);
} else {
widget.onChanged(persistence.mangaCollectionMediaFilter);
}
Navigator.pop(context);
},
);
final saveButton = BottomBarButton(
text: 'Save',
icon: Icons.save_outlined,
foregroundColor: ColorScheme.of(context).secondary,
onTap: () => ConfirmationDialog.show(
context,
title: 'Make default?',
content: 'The current filters and sorting will become the default.',
primaryAction: 'Yes',
secondaryAction: 'No',
onConfirm: () {
final notifier = ref.read(persistenceProvider.notifier);
if (widget.tag.ofAnime) {
notifier.setAnimeCollectionMediaFilter(_filter);
} else {
notifier.setMangaCollectionMediaFilter(_filter);
}
widget.onChanged(_filter);
Navigator.pop(context);
},
),
);
Widget? previewSortPicker;
if (ofViewer &&
(widget.tag.ofAnime && options.animeCollectionPreview ||
!widget.tag.ofAnime && options.mangaCollectionPreview)) {
previewSortPicker = EntrySortChipSelector(
title: 'Preview Sorting',
value: _filter.previewSort,
onChanged: (v) => _filter.previewSort = v,
highContrast: options.highContrast,
);
}
return SheetWithButtonRow(
buttons: BottomBar(
Theming.of(context).rightButtonOrientation
? [saveButton, revertToDefaultButton, applyButton]
: [applyButton, revertToDefaultButton, saveButton],
),
builder: (context, scrollCtrl) => Padding(
padding: const .symmetric(horizontal: Theming.offset),
child: ListView(
controller: scrollCtrl,
padding: const .only(top: 20),
children: [
EntrySortChipSelector(
title: 'Sorting',
value: _filter.sort,
onChanged: (v) => _filter.sort = v,
highContrast: options.highContrast,
),
?previewSortPicker,
ChipMultiSelector(
title: 'Statuses',
items: ReleaseStatus.values.map((v) => (v.label, v)).toList(),
values: _filter.statuses,
highContrast: options.highContrast,
),
ChipMultiSelector(
title: 'Formats',
items: (widget.tag.ofAnime ? MediaFormat.animeFormats : MediaFormat.mangaFormats)
.map((v) => (v.label, v))
.toList(),
values: _filter.formats,
highContrast: options.highContrast,
),
const SizedBox(height: 5),
const Divider(),
switch (ref.watch(tagsProvider)) {
AsyncData() => TagPicker(
includedGenres: _filter.genreIn,
excludedGenres: _filter.genreNotIn,
includedTags: _filter.tagIn,
excludedTags: _filter.tagNotIn,
),
AsyncError(:final error) => Center(
child: Padding(
padding: Theming.paddingAll,
child: Text('Failed to load tags: $error'),
),
),
AsyncLoading() => const Center(
child: Padding(padding: Theming.paddingAll, child: Loader()),
),
},
const Divider(),
const SizedBox(height: Theming.offset),
YearRangePicker(
title: 'Release Year Range',
from: _filter.startYearFrom,
to: _filter.startYearTo,
onChanged: (from, to) {
_filter.startYearFrom = from;
_filter.startYearTo = to;
},
),
const SizedBox(height: Theming.offset),
const Divider(),
ChipSelector(
title: 'Country',
items: OriginCountry.values.map((v) => (v.label, v)).toList(),
value: _filter.country,
onChanged: (v) => _filter.country = v,
highContrast: options.highContrast,
),
if (ofViewer)
ChipSelector(
title: 'Visibility',
items: const [('Private', true), ('Public', false)],
value: _filter.isPrivate,
onChanged: (v) => _filter.isPrivate = v,
highContrast: options.highContrast,
),
ChipSelector(
title: 'Notes',
items: const [('With Notes', true), ('Without Notes', false)],
value: _filter.hasNotes,
onChanged: (v) => _filter.hasNotes = v,
highContrast: options.highContrast,
),
SizedBox(
height: MediaQuery.paddingOf(context).bottom + BottomBar.height + Theming.offset,
),
],
),
),
);
}
}
================================================
FILE: lib/feature/collection/collection_floating_action.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/feature/collection/collection_models.dart';
import 'package:otraku/feature/collection/collection_provider.dart';
import 'package:otraku/feature/home/home_provider.dart';
import 'package:otraku/widget/input/pill_selector.dart';
import 'package:otraku/widget/swipe_switcher.dart';
import 'package:otraku/widget/sheets.dart';
class CollectionFloatingAction extends StatelessWidget {
CollectionFloatingAction(this.tag) : super(key: Key('${tag.userId}${tag.ofAnime}'));
final CollectionTag tag;
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, _) {
final collection = ref.watch(
collectionProvider(tag).select((s) => s.unwrapPrevious().value),
);
return switch (collection) {
null => const SizedBox(),
PreviewCollection _ => FloatingActionButton(
tooltip: 'Load Entire Collection',
child: const Icon(Ionicons.enter_outline),
onPressed: () => ref.read(homeProvider.notifier).expandCollection(tag.ofAnime),
),
FullCollection c => _fullCollectionActionButton(context, ref, c.lists, c.index),
};
},
);
}
Widget _fullCollectionActionButton(
BuildContext context,
WidgetRef ref,
List lists,
int index,
) {
final items = buildFullCollectionSelectionItems(context, lists);
return FloatingActionButton(
tooltip: 'Lists',
onPressed: () {
showSheet(
context,
SimpleSheet(
initialHeight: PillSelector.expectedMinHeight(lists.length),
builder: (context, scrollCtrl) => PillSelector(
scrollCtrl: scrollCtrl,
selected: index + 1,
items: items,
onTap: (index) {
ref.read(collectionProvider(tag).notifier).changeIndex(index - 1);
Navigator.pop(context);
},
),
),
);
},
child: SwipeSwitcher(
index: index + 1,
children: List.filled(lists.length + 1, const Icon(Ionicons.menu_outline)),
onChanged: (index) => ref.read(collectionProvider(tag).notifier).changeIndex(index - 1),
),
);
}
}
List buildFullCollectionSelectionItems(BuildContext context, List lists) {
final listItems = [
(name: 'All', count: lists.fold(0, (v, l) => v + l.entries.length).toString()),
...lists.map((l) => (name: l.name, count: l.entries.length.toString())),
];
final listItemToWidget = (({String name, String count}) item) => Row(
spacing: 5,
children: [
Expanded(child: Text(item.name)),
Text(item.count, style: TextTheme.of(context).labelMedium),
],
);
return listItems.map(listItemToWidget).toList();
}
================================================
FILE: lib/feature/collection/collection_grid.dart
================================================
import 'package:flutter/material.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/extension/build_context_extension.dart';
import 'package:otraku/extension/card_extension.dart';
import 'package:otraku/feature/collection/collection_models.dart';
import 'package:otraku/feature/edit/edit_view.dart';
import 'package:otraku/feature/media/media_route_tile.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/widget/cached_image.dart';
import 'package:otraku/util/debounce.dart';
import 'package:otraku/widget/dialogs.dart';
import 'package:otraku/widget/grid/sliver_grid_delegates.dart';
import 'package:otraku/widget/sheets.dart';
class CollectionGrid extends StatelessWidget {
const CollectionGrid({
required this.items,
required this.onProgressUpdated,
required this.highContrast,
});
final List items;
final Future Function(Entry, bool)? onProgressUpdated;
final bool highContrast;
@override
Widget build(BuildContext context) {
final lineHeight = context.lineHeight(TextTheme.of(context).bodyMedium!);
final extraHeight = lineHeight * 2 + 38;
return SliverGrid(
gridDelegate: SliverGridDelegateWithMinWidthAndExtraHeight(
minWidth: 100,
extraHeight: extraHeight,
rawHWRatio: Theming.coverHtoWRatio,
),
delegate: SliverChildBuilderDelegate(
childCount: items.length,
(context, i) => CardExtension.highContrast(highContrast)(
child: MediaRouteTile(
id: items[i].mediaId,
imageUrl: items[i].imageUrl,
child: Column(
crossAxisAlignment: .stretch,
children: [
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Theming.radiusSmall),
child: Container(
color: ColorScheme.of(context).surfaceContainerHighest,
child: CachedImage(items[i].imageUrl),
),
),
),
SizedBox(
height: lineHeight * 2 + 8,
child: Padding(
padding: const .only(left: 5, right: 5, top: 5, bottom: 3),
child: Text(items[i].titles[0], overflow: .ellipsis, maxLines: 2),
),
),
_IncrementButton(items[i], onProgressUpdated),
],
),
),
),
),
);
}
}
class _IncrementButton extends StatefulWidget {
const _IncrementButton(this.item, this.onProgressUpdated);
final Entry item;
final Future Function(Entry, bool)? onProgressUpdated;
@override
State<_IncrementButton> createState() => _IncrementButtonState();
}
class _IncrementButtonState extends State<_IncrementButton> {
final _debounce = Debounce();
int? _lastProgress;
@override
Widget build(BuildContext context) {
final item = widget.item;
if (item.progress == item.progressMax) {
return Tooltip(
message: 'Progress',
child: SizedBox(
height: 30,
child: Center(
child: Text(item.progress.toString(), style: TextTheme.of(context).labelSmall),
),
),
);
}
final foregroundColor = item.nextEpisode != null && item.progress + 1 < item.nextEpisode!
? ColorScheme.of(context).error
: null;
if (widget.onProgressUpdated == null) {
return Tooltip(
message: 'Progress',
child: SizedBox(
height: 30,
child: Center(
child: Text(
'${item.progress}/${item.progressMax ?? "?"}',
style: TextTheme.of(context).labelSmall?.copyWith(color: foregroundColor),
),
),
),
);
}
return TextButton(
style: TextButton.styleFrom(
minimumSize: const Size(0, 30),
padding: const .symmetric(horizontal: 5),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: foregroundColor,
iconColor: foregroundColor,
),
onPressed: () {
_debounce.cancel();
if (item.progressMax != null && item.progress >= item.progressMax! - 1) {
_resetProgress();
showSheet(context, EditView((id: item.mediaId, setComplete: true)));
return;
}
_lastProgress ??= item.progress;
setState(() => item.progress++);
_debounce.run(_update);
},
child: Tooltip(
message: 'Increment Progress',
child: Row(
mainAxisAlignment: .center,
children: [
Text(
'${item.progress}/${item.progressMax ?? "?"}',
style: const TextStyle(fontSize: Theming.fontSmall),
),
const SizedBox(width: 3),
const Icon(Ionicons.add_outline, size: Theming.iconSmall),
],
),
),
);
}
void _update() async {
final item = widget.item;
var updateStatus = false;
if (_lastProgress == 0 &&
(item.listStatus == .planning ||
item.listStatus == .paused ||
item.listStatus == .dropped)) {
await ConfirmationDialog.show(
context,
title: 'Update status?',
content: 'Do you also want to update the list status?',
primaryAction: 'Yes',
secondaryAction: 'No',
onConfirm: () => updateStatus = true,
);
}
final err = await widget.onProgressUpdated!(item, updateStatus);
if (err == null) {
_lastProgress = null;
return;
}
_resetProgress();
if (mounted) {
SnackBarExtension.show(context, 'Failed updating progress: $err');
}
}
void _resetProgress() {
if (_lastProgress == null) return;
setState(() => widget.item.progress = _lastProgress!);
_lastProgress = null;
}
}
================================================
FILE: lib/feature/collection/collection_list.dart
================================================
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/extension/build_context_extension.dart';
import 'package:otraku/extension/card_extension.dart';
import 'package:otraku/extension/date_time_extension.dart';
import 'package:otraku/feature/media/media_route_tile.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/util/debounce.dart';
import 'package:otraku/feature/collection/collection_models.dart';
import 'package:otraku/feature/edit/edit_view.dart';
import 'package:otraku/widget/cached_image.dart';
import 'package:otraku/widget/dialogs.dart';
import 'package:otraku/widget/input/note_label.dart';
import 'package:otraku/widget/input/score_label.dart';
import 'package:otraku/widget/sheets.dart';
import 'package:otraku/widget/text_rail.dart';
import 'package:otraku/feature/media/media_models.dart';
class CollectionList extends StatelessWidget {
const CollectionList({
required this.items,
required this.scoreFormat,
required this.onProgressUpdated,
required this.highContrast,
});
final List items;
final ScoreFormat scoreFormat;
final Future Function(Entry, bool)? onProgressUpdated;
final bool highContrast;
@override
Widget build(BuildContext context) {
final textTheme = TextTheme.of(context);
final bodyMediumLineHeight = context.lineHeight(textTheme.bodyMedium!);
final labelMediumLineHeight = context.lineHeight(textTheme.labelMedium!);
final tileHeight = bodyMediumLineHeight * 2 + labelMediumLineHeight * 2 + Theming.offset + 69;
return SliverFixedExtentList(
delegate: SliverChildBuilderDelegate(
(_, i) => _Tile(
items[i],
scoreFormat,
onProgressUpdated,
highContrast,
tileHeight / Theming.coverHtoWRatio,
),
childCount: items.length,
),
itemExtent: tileHeight,
);
}
}
class _Tile extends StatelessWidget {
const _Tile(
this.entry,
this.scoreFormat,
this.onProgressUpdated,
this.highContrast,
this.coverWidth,
);
final Entry entry;
final ScoreFormat scoreFormat;
final Future Function(Entry, bool)? onProgressUpdated;
final bool highContrast;
final double coverWidth;
@override
Widget build(BuildContext context) {
return CardExtension.highContrast(highContrast)(
margin: const .only(bottom: Theming.offset),
child: MediaRouteTile(
key: ValueKey(entry.mediaId),
id: entry.mediaId,
imageUrl: entry.imageUrl,
child: Row(
crossAxisAlignment: .start,
children: [
ClipRRect(
borderRadius: const BorderRadius.horizontal(left: Theming.radiusSmall),
child: DecoratedBox(
decoration: BoxDecoration(color: ColorScheme.of(context).surfaceContainerHighest),
child: CachedImage(entry.imageUrl, width: coverWidth),
),
),
Expanded(
child: Padding(
padding: Theming.paddingAll,
child: _TileContent(entry, scoreFormat, onProgressUpdated),
),
),
],
),
),
);
}
}
/// The content is a [StatefulWidget], as it
/// needs to update when the progress increments.
class _TileContent extends StatefulWidget {
const _TileContent(this.item, this.scoreFormat, this.onProgressUpdated);
final Entry item;
final ScoreFormat scoreFormat;
final Future Function(Entry, bool)? onProgressUpdated;
@override
State<_TileContent> createState() => __TileContentState();
}
class __TileContentState extends State<_TileContent> {
final _debounce = Debounce();
int? _lastProgress;
@override
Widget build(BuildContext context) {
final colorScheme = ColorScheme.of(context);
final item = widget.item;
double progressPercent = 0;
if (item.progressMax != null) {
progressPercent = item.progress / item.progressMax!;
} else if (item.nextEpisode != null) {
progressPercent = item.progress / (item.nextEpisode! - 1);
} else if (item.progress > 0) {
progressPercent = 1;
}
final textRailItems = {};
if (item.format != null) {
textRailItems[item.format!.label] = false;
}
if (item.airingAt != null) {
final key = 'Ep ${item.nextEpisode} in ${item.airingAt!.timeUntil}';
textRailItems[key] = false;
}
if (item.nextEpisode != null && item.nextEpisode! - 1 > item.progress) {
final key = '${item.nextEpisode! - 1 - item.progress} ep behind';
textRailItems[key] = true;
}
return Column(
mainAxisAlignment: .spaceAround,
crossAxisAlignment: .stretch,
children: [
Flexible(child: Text(widget.item.titles[0], overflow: .ellipsis, maxLines: 2)),
TextRail(textRailItems, maxLines: 2),
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: SizedBox(
height: 3,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: Theming.borderRadiusSmall,
gradient: LinearGradient(
colors: [
colorScheme.onSurfaceVariant,
colorScheme.onSurfaceVariant,
colorScheme.surfaceContainerHighest,
colorScheme.surfaceContainerHighest,
],
stops: [0.0, progressPercent, progressPercent, 1.0],
),
),
),
),
),
Row(
mainAxisAlignment: .spaceBetween,
children: [
ScoreLabel(item.score, widget.scoreFormat),
if (item.repeat > 0)
Tooltip(
message: 'Repeats',
child: Row(
mainAxisSize: .min,
spacing: 3,
children: [
const Icon(Ionicons.repeat, size: Theming.iconSmall),
Text(item.repeat.toString(), style: TextTheme.of(context).labelSmall),
],
),
)
else
const SizedBox(),
NotesLabel(item.notes),
_buildProgressButton(context),
],
),
],
);
}
Widget _buildProgressButton(BuildContext context) {
final item = widget.item;
final foregroundColor = item.nextEpisode != null && item.progress + 1 < item.nextEpisode!
? ColorScheme.of(context).error
: ColorScheme.of(context).onSurfaceVariant;
final text = Text(
item.progress == item.progressMax
? item.progress.toString()
: '${item.progress}/${item.progressMax ?? "?"}',
style: TextTheme.of(context).labelSmall?.copyWith(color: foregroundColor),
);
if (widget.onProgressUpdated == null || item.progress == item.progressMax) {
return Tooltip(message: 'Progress', child: text);
}
return TextButton(
style: TextButton.styleFrom(
minimumSize: const Size(0, 40),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: foregroundColor,
iconColor: foregroundColor,
),
onPressed: () {
_debounce.cancel();
if (item.progressMax != null && item.progress >= item.progressMax! - 1) {
_resetProgress();
showSheet(context, EditView((id: item.mediaId, setComplete: true)));
return;
}
_lastProgress ??= item.progress;
setState(() => item.progress++);
_debounce.run(_update);
},
child: Tooltip(
message: 'Increment Progress',
child: Row(
spacing: 3,
children: [
text,
const Icon(Ionicons.add_outline, size: Theming.iconSmall),
],
),
),
);
}
void _update() async {
final item = widget.item;
var updateStatus = false;
if (_lastProgress == 0 &&
(item.listStatus == .planning ||
item.listStatus == .paused ||
item.listStatus == .dropped)) {
await ConfirmationDialog.show(
context,
title: 'Update status?',
content: 'Do you also want to update the list status?',
primaryAction: 'Yes',
secondaryAction: 'No',
onConfirm: () => updateStatus = true,
);
}
final err = await widget.onProgressUpdated!(item, updateStatus);
if (err == null) {
_lastProgress = null;
return;
}
_resetProgress();
if (mounted) {
SnackBarExtension.show(context, 'Failed updating progress: $err');
}
}
void _resetProgress() {
if (_lastProgress == null) return;
setState(() => widget.item.progress = _lastProgress!);
_lastProgress = null;
}
}
================================================
FILE: lib/feature/collection/collection_models.dart
================================================
import 'package:otraku/extension/date_time_extension.dart';
import 'package:otraku/extension/iterable_extension.dart';
import 'package:otraku/feature/viewer/persistence_model.dart';
import 'package:otraku/feature/media/media_models.dart';
typedef CollectionTag = ({int userId, bool ofAnime});
enum CollectionItemView { detailed, simple }
sealed class Collection {
const Collection({required this.scoreFormat});
final ScoreFormat scoreFormat;
String get listName;
void sort(EntrySort s);
}
class PreviewCollection extends Collection {
const PreviewCollection._({required this.list, required super.scoreFormat});
factory PreviewCollection(Map map, ImageQuality imageQuality) {
final entries = [];
for (final l in map['lists']) {
if (l['isCustomList']) continue;
for (final e in l['entries']) {
entries.add(Entry(e, imageQuality));
}
}
return PreviewCollection._(
list: EntryList._(
name: 'Preview',
entries: entries,
status: null,
splitCompletedListFormat: null,
),
scoreFormat: ScoreFormat.from(map['user']['mediaListOptions']['scoreFormat']),
);
}
final EntryList list;
@override
String get listName => 'Preview';
@override
void sort(EntrySort s) {
list.entries.sort(_entryComparator(s));
}
}
class FullCollection extends Collection {
const FullCollection._({required this.lists, required this.index, required super.scoreFormat});
factory FullCollection(
Map map,
bool ofAnime,
int index,
ImageQuality imageQuality,
) {
final maps = map['lists'] as List;
final lists = [];
final metaData = map['user']['mediaListOptions'][ofAnime ? 'animeList' : 'mangaList'];
bool splitCompleted = metaData['splitCompletedSectionByFormat'] ?? false;
for (final String section in metaData['sectionOrder']) {
final pos = maps.indexWhere((l) => l['name'] == section);
if (pos == -1) continue;
final l = maps.removeAt(pos);
lists.add(EntryList(l, splitCompleted, imageQuality));
}
for (final l in maps) {
lists.add(EntryList(l, splitCompleted, imageQuality));
}
if (index >= lists.length) index = 0;
return FullCollection._(
lists: lists,
index: index,
scoreFormat: ScoreFormat.from(map['user']['mediaListOptions']['scoreFormat']),
);
}
final List lists;
final int index;
@override
String get listName => index < 0 ? 'All' : lists[index].name;
@override
void sort(EntrySort s) {
final comparator = _entryComparator(s);
for (final l in lists) {
l.entries.sort(comparator);
}
}
FullCollection withIndex(int newIndex) => newIndex == index
? this
: FullCollection._(lists: lists, index: newIndex, scoreFormat: scoreFormat);
}
class EntryList {
const EntryList._({
required this.name,
required this.entries,
required this.status,
required this.splitCompletedListFormat,
});
factory EntryList(Map map, bool splitCompleted, ImageQuality imageQuality) {
final status = !map['isCustomList'] ? ListStatus.from(map['status']) : null;
return EntryList._(
name: map['name'],
status: status,
splitCompletedListFormat: splitCompleted && status == .completed
? MediaFormat.from(map['entries'][0]['media']['format'])
: null,
entries: (map['entries'] as List).map((e) => Entry(e, imageQuality)).toList(),
);
}
final String name;
final List entries;
/// The [ListStatus] of the [entries] in this list.
/// If `null`, this is a custom list.
final ListStatus? status;
/// If the user's "completed" list is split by format and this is one of the
/// resulting lists, [splitCompletedListFormat] is the corresponding format.
final MediaFormat? splitCompletedListFormat;
bool setByMediaId(Entry entry) {
for (int i = 0; i < entries.length; i++) {
if (entries[i].mediaId == entry.mediaId) {
entries[i] = entry;
return true;
}
}
return false;
}
void removeByMediaId(int id) {
for (int i = 0; i < entries.length; i++) {
if (entries[i].mediaId == id) {
entries.removeAt(i);
return;
}
}
}
void insertSorted(Entry entry, EntrySort s) {
final compare = _entryComparator(s);
for (int i = 0; i < entries.length; i++) {
if (compare(entry, entries[i]) <= 0) {
entries.insert(i, entry);
return;
}
}
entries.add(entry);
}
void sort(EntrySort s) => entries.sort(_entryComparator(s));
EntryList copyWithEntries(List entries) => EntryList._(
name: name,
entries: entries,
status: status,
splitCompletedListFormat: splitCompletedListFormat,
);
}
/// Returns a [Comparator] for [Entry], based on an [EntrySort].
int Function(Entry, Entry) _entryComparator(EntrySort s) => switch (s) {
.title => (a, b) => a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase()),
.titleDesc => (a, b) => b.titles[0].compareTo(a.titles[0]),
.score => (a, b) {
final comparison = a.score.compareTo(b.score);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.scoreDesc => (a, b) {
final comparison = b.score.compareTo(a.score);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.updated => (a, b) {
final comparison = a.updatedAt!.compareTo(b.updatedAt!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.updatedDesc => (a, b) {
final comparison = b.updatedAt!.compareTo(a.updatedAt!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.added => (a, b) {
final comparison = a.createdAt!.compareTo(b.createdAt!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.addedDesc => (a, b) {
final comparison = b.createdAt!.compareTo(a.createdAt!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.progress => (a, b) {
final comparison = a.progress.compareTo(b.progress);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.progressDesc => (a, b) {
final comparison = b.progress.compareTo(a.progress);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.repeated => (a, b) {
final comparison = a.repeat.compareTo(b.repeat);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.repeatedDesc => (a, b) {
final comparison = b.repeat.compareTo(a.repeat);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.airing => (a, b) {
if (a.airingAt == null) {
if (b.airingAt == null) {
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
}
return 1;
}
if (b.airingAt == null) return -1;
final comparison = a.airingAt!.compareTo(b.airingAt!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.airingDesc => (a, b) {
if (b.airingAt == null) {
if (a.airingAt == null) {
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
}
return -1;
}
if (a.airingAt == null) return 1;
final comparison = b.airingAt!.compareTo(a.airingAt!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.releasedOn => (a, b) {
if (a.releaseStart == null) {
if (b.releaseStart == null) {
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
}
return 1;
}
if (b.releaseStart == null) return -1;
final comparison = a.releaseStart!.compareTo(b.releaseStart!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.releasedOnDesc => (a, b) {
if (b.releaseStart == null) {
if (a.releaseStart == null) {
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
}
return -1;
}
if (a.releaseStart == null) return 1;
final comparison = b.releaseStart!.compareTo(a.releaseStart!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.startedOn => (a, b) {
if (a.watchStart == null) {
if (b.watchStart == null) {
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
}
return 1;
}
if (b.watchStart == null) return -1;
final comparison = a.watchStart!.compareTo(b.watchStart!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.startedOnDesc => (a, b) {
if (b.watchStart == null) {
if (a.watchStart == null) {
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
}
return -1;
}
if (a.watchStart == null) return 1;
final comparison = b.watchStart!.compareTo(a.watchStart!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.completedOn => (a, b) {
if (a.watchEnd == null) {
if (b.watchEnd == null) {
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
}
return 1;
}
if (b.watchEnd == null) return -1;
final comparison = a.watchEnd!.compareTo(b.watchEnd!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.completedOnDesc => (a, b) {
if (b.watchEnd == null) {
if (a.watchEnd == null) {
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
}
return -1;
}
if (a.watchEnd == null) return 1;
final comparison = b.watchEnd!.compareTo(a.watchEnd!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.avgScore => (a, b) {
if (a.avgScore == null) {
if (b.avgScore == null) {
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
}
return 1;
}
if (b.avgScore == null) return -1;
final comparison = a.avgScore!.compareTo(b.avgScore!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
.avgScoreDesc => (a, b) {
if (b.avgScore == null) {
if (a.avgScore == null) {
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
}
return -1;
}
if (a.avgScore == null) return 1;
final comparison = b.avgScore!.compareTo(a.avgScore!);
if (comparison != 0) return comparison;
return a.titles[0].toUpperCase().compareTo(b.titles[0].toUpperCase());
},
};
class Entry {
Entry._({
required this.mediaId,
required this.titles,
required this.imageUrl,
required this.format,
required this.releaseStatus,
required this.listStatus,
required this.nextEpisode,
required this.airingAt,
required this.createdAt,
required this.updatedAt,
required this.country,
required this.isPrivate,
required this.genres,
required this.tagIds,
required this.progressMax,
required this.progress,
required this.repeat,
required this.score,
required this.notes,
required this.avgScore,
required this.releaseStart,
required this.watchStart,
required this.watchEnd,
});
factory Entry(Map map, ImageQuality imageQuality) {
final titles = [map['media']['title']['userPreferred']];
if (map['media']['title']['english'] != null) {
titles.add(map['media']['title']['english']);
}
if (map['media']['title']['romaji'] != null) {
titles.add(map['media']['title']['romaji']);
}
if (map['media']['title']['native'] != null) {
titles.add(map['media']['title']['native']);
}
final tagIds = [];
for (final t in map['media']['tags']) {
tagIds.add(t['id']);
}
return Entry._(
mediaId: map['media']['id'],
titles: titles,
imageUrl: map['media']['coverImage'][imageQuality.value],
format: MediaFormat.from(map['media']['format']),
releaseStatus: ReleaseStatus.from(map['media']['status']),
listStatus: ListStatus.from(map['status']),
nextEpisode: map['media']['nextAiringEpisode']?['episode'],
airingAt: DateTimeExtension.tryFromSecondsSinceEpoch(
map['media']['nextAiringEpisode']?['airingAt'],
),
createdAt: map['createdAt'],
updatedAt: map['updatedAt'],
country: map['media']['countryOfOrigin'],
isPrivate: map['private'] ?? false,
genres: List.from(map['media']['genres'] ?? [], growable: false),
tagIds: tagIds,
progressMax: map['media']['episodes'] ?? map['media']['chapters'],
progress: map['progress'] ?? 0,
repeat: map['repeat'] ?? 0,
score: map['score'].toDouble() ?? 0.0,
notes: map['notes'] ?? '',
avgScore: map['media']['averageScore'],
releaseStart: DateTimeExtension.fromFuzzyDate(map['media']['startDate']),
watchStart: DateTimeExtension.fromFuzzyDate(map['startedAt']),
watchEnd: DateTimeExtension.fromFuzzyDate(map['completedAt']),
);
}
final int mediaId;
final List titles;
final String imageUrl;
final MediaFormat? format;
final ReleaseStatus? releaseStatus;
final ListStatus? listStatus;
final int? nextEpisode;
final DateTime? airingAt;
final int? createdAt;
final int? updatedAt;
final String? country;
final bool isPrivate;
final List genres;
final List tagIds;
final int? progressMax;
int progress;
int repeat;
double score;
String notes;
int? avgScore;
DateTime? releaseStart;
DateTime? watchStart;
DateTime? watchEnd;
}
enum ListStatus {
current('CURRENT'),
planning('PLANNING'),
completed('COMPLETED'),
dropped('DROPPED'),
paused('PAUSED'),
repeating('REPEATING');
const ListStatus(this.value);
final String value;
String label(bool? ofAnime) => switch (this) {
current =>
ofAnime == null
? 'Current'
: ofAnime
? 'Watching'
: 'Reading',
repeating =>
ofAnime == null
? 'Repeating'
: ofAnime
? 'Rewatching'
: 'Rereading',
completed => 'Completed',
paused => 'Paused',
planning => 'Planning',
dropped => 'Dropped',
};
static ListStatus? from(String? value) =>
ListStatus.values.firstWhereOrNull((v) => v.value == value);
}
================================================
FILE: lib/feature/collection/collection_provider.dart
================================================
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/extension/date_time_extension.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/feature/collection/collection_models.dart';
import 'package:otraku/feature/home/home_provider.dart';
import 'package:otraku/feature/media/media_models.dart';
import 'package:otraku/feature/viewer/repository_provider.dart';
import 'package:otraku/util/graphql.dart';
final collectionProvider = AsyncNotifierProvider.autoDispose
.family(CollectionNotifier.new);
class CollectionNotifier extends AsyncNotifier {
CollectionNotifier(this.arg);
final CollectionTag arg;
var _sort = EntrySort.title;
@override
FutureOr build() async {
final fullCollectionIndex = switch (state.value) {
FullCollection c => c.index,
_ => -1,
};
final viewerId = ref.watch(viewerIdProvider);
final isFull =
arg.userId != viewerId ||
ref.watch(
homeProvider.select(
(s) => arg.ofAnime ? s.didExpandAnimeCollection : s.didExpandMangaCollection,
),
);
final data = await ref.read(repositoryProvider).request(GqlQuery.collection, {
'userId': arg.userId,
'type': arg.ofAnime ? 'ANIME' : 'MANGA',
if (!isFull) 'status_in': ['CURRENT', 'REPEATING'],
});
final imageQuality = ref.read(persistenceProvider).options.imageQuality;
final collection = isFull
? FullCollection(
data['MediaListCollection'],
arg.ofAnime,
fullCollectionIndex,
imageQuality,
)
: PreviewCollection(data['MediaListCollection'], imageQuality);
collection.sort(_sort);
return collection;
}
void ensureSorted(EntrySort sort, EntrySort previewSort) {
_updateState((collection) {
final selectedSort = switch (collection) {
FullCollection _ => sort,
PreviewCollection _ => previewSort,
};
if (_sort == selectedSort) return;
_sort = selectedSort;
collection.sort(selectedSort);
return null;
});
}
void changeIndex(int newIndex) => _updateState(
(collection) => switch (collection) {
FullCollection _ => collection.withIndex(newIndex),
PreviewCollection _ => collection,
},
);
void removeEntry(int mediaId) {
_updateState(
(collection) => switch (collection) {
PreviewCollection c => c..list.removeByMediaId(mediaId),
FullCollection c => _withRemovedEmptyLists(
c..lists.forEach((list) => list.removeByMediaId(mediaId)),
),
},
);
}
/// There is an api bug in entry updating,
/// which prevents tag data from being returned.
/// This is why [saveEntry] additionally fetches the updated entry.
Future saveEntry(int mediaId, ListStatus? oldStatus) async {
try {
var data = await ref.read(repositoryProvider).request(GqlQuery.listEntry, {
'userId': arg.userId,
'mediaId': mediaId,
});
data = data['MediaList'];
final entry = Entry(data, ref.read(persistenceProvider).options.imageQuality);
_updateState(
(collection) => switch (collection) {
FullCollection _ => _saveEntryInFullCollection(collection, entry, oldStatus, data),
PreviewCollection _ => _saveEntryInPreviewCollection(
collection,
entry,
oldStatus,
entry.listStatus,
),
},
);
} catch (_) {}
}
/// An alternative to [saveEntry],
/// that only updates the progress and potentially, the list status.
/// When incrementing to last episode, [saveEntry] should be called instead.
Future saveEntryProgress(Entry oldEntry, bool setAsCurrent) async {
try {
await ref.read(repositoryProvider).request(GqlMutation.updateProgress, {
'mediaId': oldEntry.mediaId,
'progress': oldEntry.progress,
if (setAsCurrent) ...{
'status': ListStatus.current.value,
if (oldEntry.watchStart == null) 'startedAt': DateTime.now().fuzzyDate,
},
});
await saveEntry(oldEntry.mediaId, oldEntry.listStatus);
return null;
} catch (e) {
return e.toString();
}
}
FullCollection _saveEntryInFullCollection(
FullCollection collection,
Entry entry,
ListStatus? oldStatus,
Map data,
) {
final hiddenFromStatusLists = data['hiddenFromStatusLists'] ?? false;
final customListItems = data['customLists'] ?? const {};
final customLists = customListItems.entries
.where((e) => e.value == true)
.map((e) => e.key.toLowerCase())
.toList();
for (final list in collection.lists) {
if (list.status != null) {
if (list.status == oldStatus) {
if (list.status == entry.listStatus) {
if (hiddenFromStatusLists) {
list.removeByMediaId(entry.mediaId);
continue;
}
if (!list.setByMediaId(entry)) {
list.insertSorted(entry, _sort);
}
continue;
}
list.removeByMediaId(entry.mediaId);
continue;
}
if (list.status == entry.listStatus) {
list.insertSorted(entry, _sort);
}
continue;
}
if (customLists.contains(list.name.toLowerCase())) {
if (!list.setByMediaId(entry)) {
list.insertSorted(entry, _sort);
}
continue;
}
list.removeByMediaId(entry.mediaId);
}
return _withRemovedEmptyLists(collection);
}
PreviewCollection _saveEntryInPreviewCollection(
PreviewCollection collection,
Entry entry,
ListStatus? oldStatus,
ListStatus? newStatus,
) {
if (newStatus == .current || newStatus == .repeating) {
if (oldStatus == .current || oldStatus == .repeating) {
collection.list.setByMediaId(entry);
return collection;
}
collection.list.insertSorted(entry, _sort);
return collection;
}
collection.list.removeByMediaId(entry.mediaId);
return collection;
}
FullCollection _withRemovedEmptyLists(FullCollection collection) {
final lists = collection.lists;
int index = collection.index;
for (int i = 0; i < lists.length; i++) {
if (lists[i].entries.isEmpty) {
if (i <= index) index--;
lists.removeAt(i--);
}
}
return collection.withIndex(index);
}
void _updateState(Collection? Function(Collection) mutator) {
if (!state.hasValue) return;
final result = mutator(state.value!);
if (result != null) state = AsyncValue.data(result);
}
}
================================================
FILE: lib/feature/collection/collection_top_bar.dart
================================================
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/feature/collection/collection_entries_provider.dart';
import 'package:otraku/feature/collection/collection_filter_provider.dart';
import 'package:otraku/feature/collection/collection_models.dart';
import 'package:otraku/feature/collection/collection_provider.dart';
import 'package:otraku/feature/collection/collection_filter_view.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/util/debounce.dart';
import 'package:otraku/widget/input/search_field.dart';
import 'package:otraku/widget/dialogs.dart';
import 'package:otraku/widget/sheets.dart';
class CollectionTopBarTrailingContent extends StatelessWidget {
const CollectionTopBarTrailingContent(this.tag, this.focusNode);
final CollectionTag tag;
final FocusNode? focusNode;
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, _) {
final filter = ref.watch(collectionFilterProvider(tag));
final filterIcon = IconButton(
tooltip: 'Filter',
icon: const Icon(Ionicons.funnel_outline),
onPressed: () => showSheet(
context,
CollectionFilterView(
tag: tag,
filter: filter.mediaFilter,
onChanged: (mediaFilter) => ref
.read(collectionFilterProvider(tag).notifier)
.update((s) => s.copyWith(mediaFilter: mediaFilter)),
),
),
);
return Expanded(
child: Row(
children: [
Expanded(
child: SearchField(
debounce: Debounce(),
focusNode: focusNode,
hint: ref.watch(collectionProvider(tag).select((s) => s.value?.listName ?? '')),
value: filter.search,
onChanged: (search) => ref
.read(collectionFilterProvider(tag).notifier)
.update((s) => s.copyWith(search: search)),
),
),
IconButton(
tooltip: 'Random',
icon: const Icon(Ionicons.shuffle_outline),
onPressed: () {
final lists = ref.read(collectionEntriesProvider(tag));
if (lists.isEmpty) {
ConfirmationDialog.show(context, title: 'No entries');
return;
}
final list = lists[Random().nextInt(lists.length)];
if (list.entries.isEmpty) {
ConfirmationDialog.show(context, title: 'No entries');
return;
}
final entry = list.entries[Random().nextInt(list.entries.length)];
context.push(Routes.media(entry.mediaId, entry.imageUrl));
},
),
if (filter.mediaFilter.isActive)
Badge(
smallSize: 10,
alignment: Alignment.topLeft,
backgroundColor: ColorScheme.of(context).primary,
child: filterIcon,
)
else
filterIcon,
],
),
);
},
);
}
}
================================================
FILE: lib/feature/collection/collection_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:otraku/feature/collection/collection_floating_action.dart';
import 'package:otraku/feature/collection/collection_top_bar.dart';
import 'package:otraku/feature/discover/discover_filter_model.dart';
import 'package:otraku/feature/discover/discover_filter_provider.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/feature/collection/collection_entries_provider.dart';
import 'package:otraku/feature/collection/collection_filter_provider.dart';
import 'package:otraku/feature/collection/collection_grid.dart';
import 'package:otraku/feature/collection/collection_models.dart';
import 'package:otraku/feature/collection/collection_provider.dart';
import 'package:otraku/widget/input/pill_selector.dart';
import 'package:otraku/widget/layout/adaptive_scaffold.dart';
import 'package:otraku/widget/layout/constrained_view.dart';
import 'package:otraku/widget/layout/hiding_floating_action_button.dart';
import 'package:otraku/widget/layout/top_bar.dart';
import 'package:otraku/widget/loaders.dart';
import 'package:otraku/feature/collection/collection_list.dart';
class CollectionView extends StatefulWidget {
const CollectionView(this.userId, this.ofAnime);
final int userId;
final bool ofAnime;
@override
State createState() => _CollectionViewState();
}
class _CollectionViewState extends State {
final _ctrl = ScrollController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tag = (userId: widget.userId, ofAnime: widget.ofAnime);
final formFactor = Theming.of(context).formFactor;
return AdaptiveScaffold(
topBar: TopBar(trailing: [CollectionTopBarTrailingContent(tag, null)]),
floatingAction: formFactor == .phone
? HidingFloatingActionButton(
key: const Key('lists'),
scrollCtrl: _ctrl,
child: CollectionFloatingAction(tag),
)
: null,
child: CollectionSubview(tag: tag, scrollCtrl: _ctrl, formFactor: formFactor),
);
}
}
class CollectionSubview extends StatelessWidget {
const CollectionSubview({
required this.tag,
required this.scrollCtrl,
required this.formFactor,
super.key,
});
final CollectionTag? tag;
final ScrollController scrollCtrl;
final FormFactor formFactor;
@override
Widget build(BuildContext context) {
if (tag == null) {
return const Center(
child: Padding(
padding: Theming.paddingAll,
child: Text('Log in from the profile tab to view your collections', textAlign: .center),
),
);
}
return Consumer(
builder: (context, ref, _) {
ref.listen(
collectionProvider(tag!),
(_, s) =>
s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())),
);
return ref
.watch(collectionProvider(tag!))
.unwrapPrevious()
.when(
loading: () => const Center(child: Loader()),
error: (_, _) => CustomScrollView(
physics: Theming.bouncyPhysics,
slivers: [
SliverRefreshControl(onRefresh: () => ref.invalidate(collectionProvider(tag!))),
const SliverFillRemaining(child: Center(child: Text('Failed to load'))),
],
),
data: (data) {
final content = Scrollbar(
controller: scrollCtrl,
child: ConstrainedView(
child: CustomScrollView(
physics: Theming.bouncyPhysics,
controller: scrollCtrl,
slivers: [
SliverRefreshControl(
onRefresh: () => ref.invalidate(collectionProvider(tag!)),
),
_Content(tag!, data),
const SliverFooter(),
],
),
),
);
if (formFactor == .phone) return content;
return switch (data) {
PreviewCollection _ => content,
FullCollection c => Row(
children: [
PillSelector(
maxWidth: 200,
selected: c.index + 1,
items: buildFullCollectionSelectionItems(context, data.lists),
onTap: (i) =>
ref.read(collectionProvider(tag!).notifier).changeIndex(i - 1),
),
Expanded(child: content),
],
),
};
},
);
},
);
}
}
class _Content extends StatelessWidget {
const _Content(this.tag, this.collection);
final CollectionTag tag;
final Collection collection;
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, _) {
final lists = ref.watch(collectionEntriesProvider(tag));
final isViewer = ref.watch(viewerIdProvider) == tag.userId;
if (lists.isEmpty) {
if (!isViewer) {
return const SliverFillRemaining(child: Center(child: Text('No results')));
}
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: .min,
children: [
const Text('No results'),
TextButton(
onPressed: () => _searchGlobally(context, ref),
child: const Text('Search Globally'),
),
],
),
),
);
}
final options = ref.watch(persistenceProvider.select((s) => s.options));
final onProgressUpdated = isViewer
? (oldEntry, setAsCurrent) => ref
.read(collectionProvider(tag).notifier)
.saveEntryProgress(oldEntry, setAsCurrent)
: null;
final (collectionIsExpanded, showAllLists) = switch (collection) {
PreviewCollection _ => (false, false),
FullCollection c => (true, c.index < 0),
};
final useSimpleGrid =
collectionIsExpanded && options.collectionItemView == .simple ||
!collectionIsExpanded && options.collectionPreviewItemView == .simple;
if (!showAllLists) {
return useSimpleGrid
? CollectionGrid(
items: lists[0].entries,
onProgressUpdated: onProgressUpdated,
highContrast: options.highContrast,
)
: CollectionList(
items: lists[0].entries,
onProgressUpdated: onProgressUpdated,
scoreFormat: ref.watch(
collectionProvider(tag).select((s) => s.value?.scoreFormat ?? .point10Decimal),
),
highContrast: options.highContrast,
);
}
return SliverMainAxisGroup(
slivers: [
for (final l in lists) ...[
SliverToBoxAdapter(
child: Padding(
padding: const .only(bottom: Theming.offset),
child: Text(l.name, style: TextTheme.of(context).bodyLarge),
),
),
useSimpleGrid
? CollectionGrid(
items: l.entries,
onProgressUpdated: onProgressUpdated,
highContrast: options.highContrast,
)
: CollectionList(
items: l.entries,
onProgressUpdated: onProgressUpdated,
scoreFormat: ref.watch(
collectionProvider(
tag,
).select((s) => s.value?.scoreFormat ?? .point10Decimal),
),
highContrast: options.highContrast,
),
],
],
);
},
);
}
void _searchGlobally(BuildContext context, WidgetRef ref) {
final collectionFilter = ref.read(collectionFilterProvider(tag));
final sort = ref.read(persistenceProvider).discoverMediaFilter.sort;
ref
.read(discoverFilterProvider.notifier)
.update(
(f) => f.copyWith(
type: tag.ofAnime ? .anime : .manga,
search: collectionFilter.search,
mediaFilter: DiscoverMediaFilter.fromCollection(
filter: collectionFilter.mediaFilter,
sort: sort,
ofAnime: tag.ofAnime,
),
),
);
context.go(Routes.home(.discover));
ref.invalidate(collectionFilterProvider(tag));
}
}
================================================
FILE: lib/feature/comment/comment_model.dart
================================================
import 'package:otraku/extension/date_time_extension.dart';
import 'package:otraku/util/markdown.dart';
class Comment {
Comment._({
required this.id,
required this.text,
required this.likeCount,
required this.isLiked,
required this.isLocked,
required this.createdAt,
required this.siteUrl,
required this.userId,
required this.userName,
required this.userAvatarUrl,
required this.threadId,
required this.threadTitle,
required this.childComments,
});
factory Comment(Map map) {
final childComments = [];
for (final c in map['childComments'] ?? const []) {
childComments.add(Comment(c));
}
return Comment._(
id: map['id'],
text: parseMarkdown(map['comment'] ?? ''),
likeCount: map['likeCount'] ?? 0,
isLiked: map['isLiked'] ?? false,
isLocked: map['isLocked'] ?? false,
createdAt: DateTimeExtension.fromSecondsSinceEpoch(map['createdAt']),
siteUrl: map['siteUrl'] ?? '',
userId: map['user']?['id'] ?? 0,
userName: map['user']?['name'] ?? '?',
userAvatarUrl: map['user']?['avatar']?['large'] ?? '',
threadId: map['thread']?['id'] ?? 0,
threadTitle: map['thread']?['title'] ?? '',
childComments: childComments,
);
}
final int id;
final String text;
int likeCount;
bool isLiked;
final bool isLocked;
final DateTime createdAt;
final String siteUrl;
final int userId;
final String userName;
final String userAvatarUrl;
final int threadId;
final String threadTitle;
final List childComments;
Comment _copyWith({String? text, List? childComments}) => Comment._(
id: id,
text: text ?? this.text,
likeCount: likeCount,
isLiked: isLiked,
isLocked: isLocked,
createdAt: createdAt,
siteUrl: siteUrl,
userId: userId,
userName: userName,
userAvatarUrl: userAvatarUrl,
threadId: threadId,
threadTitle: threadTitle,
childComments: childComments ?? this.childComments,
);
Comment withEditedText(String text) => _copyWith(text: text);
Comment withAppendedChildComment(Map map, int parentCommentId) {
if (id == parentCommentId) {
return _copyWith(childComments: [...childComments, Comment(map)]);
}
for (final comment in childComments) {
if (comment.append(map, parentCommentId)) {
return _copyWith(childComments: [...childComments]);
}
}
return this;
}
bool append(Map map, int parentCommentId) {
for (final comment in childComments) {
if (comment.id == parentCommentId) {
comment.childComments.add(Comment(map));
return true;
}
if (comment.append(map, parentCommentId)) {
return true;
}
}
return false;
}
}
================================================
FILE: lib/feature/comment/comment_provider.dart
================================================
import 'dart:async';
import 'dart:collection';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/extension/future_extension.dart';
import 'package:otraku/feature/comment/comment_model.dart';
import 'package:otraku/feature/viewer/repository_provider.dart';
import 'package:otraku/util/graphql.dart';
final commentProvider = AsyncNotifierProvider.autoDispose.family(
CommentNotifier.new,
);
class CommentNotifier extends AsyncNotifier {
CommentNotifier(this.arg);
final int arg;
@override
FutureOr build() async {
final data = await ref.read(repositoryProvider).request(GqlQuery.comment, {'id': arg});
// The response is a list of comments that match the filter criteria.
// Since we're filtering by id, we expect exactly one comment.
final comments = data['ThreadComment'];
if (comments.isEmpty) {
throw Exception('Not Found');
}
// The response always starts from the root comment,
// even if a subcomment was requested.
// We search for the requested subcomment with BFS.
final queue = Queue>();
queue.add(comments[0]);
while (queue.isNotEmpty) {
final comment = queue.removeFirst();
if (comment['id'] == arg) {
return Comment(comment);
}
for (final child in comment['childComments'] ?? const []) {
queue.addLast(child);
}
}
throw Exception('Not Found');
}
void edit(Map map) =>
state = state.whenData((data) => data.withEditedText(map['comment']));
Future toggleCommentLike(int commentId) {
return ref.read(repositoryProvider).request(GqlMutation.toggleLike, {
'id': commentId,
'type': 'THREAD_COMMENT',
}).getErrorOrNull();
}
void appendComment(Map map, int parentCommentId) {
final value = state.value;
if (value == null) return;
state = AsyncValue.data(value.withAppendedChildComment(map, parentCommentId));
}
Future delete() =>
ref.read(repositoryProvider).request(GqlMutation.deleteComment, {'id': arg}).getErrorOrNull();
}
================================================
FILE: lib/feature/comment/comment_tile.dart
================================================
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/feature/composition/composition_model.dart';
import 'package:otraku/feature/composition/composition_view.dart';
import 'package:otraku/feature/comment/comment_model.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/cached_image.dart';
import 'package:otraku/widget/html_content.dart';
import 'package:otraku/widget/sheets.dart';
import 'package:otraku/widget/timestamp.dart';
const _maxCommentDepth = 6;
typedef CommentTileInteraction = ({
void Function(Map map, int commentId) onReplySaved,
Future Function(int commentId) toggleLike,
});
class CommentTile extends StatelessWidget {
const CommentTile(
this.comment, {
required this.viewerId,
required this.highContrast,
required this.analogClock,
this.interaction,
this.depth = 0,
});
final Comment comment;
final CommentTileInteraction? interaction;
final int? viewerId;
final bool highContrast;
final bool analogClock;
final int depth;
@override
Widget build(BuildContext context) {
final userRow = Row(
spacing: Theming.offset,
children: [
GestureDetector(
onTap: () => context.push(Routes.user(comment.userId, comment.userAvatarUrl)),
child: ClipRRect(
borderRadius: Theming.borderRadiusSmall,
child: CachedImage(comment.userAvatarUrl, height: 50, width: 50),
),
),
Expanded(
child: OverflowBar(
spacing: 5,
overflowSpacing: 5,
children: [
Text(comment.userName, overflow: .ellipsis, maxLines: 1),
Timestamp(
comment.createdAt,
analogClock,
leading: Text('replied', style: TextTheme.of(context).labelSmall),
),
],
),
),
],
);
final contentColumn = Padding(
padding: const .only(left: Theming.offset, top: Theming.offset),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Padding(padding: const .only(right: 10, bottom: 5), child: HtmlContent(comment.text)),
Padding(
padding: const .only(right: 10, bottom: 10),
child: Row(
spacing: Theming.offset,
children: [
if (comment.isLocked)
Tooltip(
message: 'Locked',
triggerMode: .tap,
child: Icon(Icons.lock_outline_rounded, size: Theming.iconSmall),
),
const Spacer(),
if (interaction != null) ...[
if (comment.userId != viewerId)
Tooltip(
message: 'Reply',
child: InkResponse(
radius: Theming.radiusSmall.x,
onTap: () => showSheet(
context,
CompositionView(
tag: CommentCompositionTag(
threadId: comment.threadId,
parentCommentId: comment.id,
),
onSaved: (map) => interaction!.onReplySaved(map, comment.id),
),
),
child: Row(
spacing: 5,
children: [
Text(
comment.childComments.length.toString(),
style: TextTheme.of(context).labelSmall,
),
const Icon(Icons.reply_all_rounded, size: Theming.iconSmall),
],
),
),
)
else
Tooltip(
message: 'Replies',
child: InkResponse(
radius: Theming.radiusSmall.x,
onTap: () => context.push(Routes.comment(comment.id)),
child: Row(
spacing: 5,
children: [
Text(
comment.childComments.length.toString(),
style: TextTheme.of(context).labelSmall,
),
const Icon(Icons.reply_all_rounded, size: Theming.iconSmall),
],
),
),
),
_LikeButton(comment, interaction!.toggleLike),
] else ...[
SizedBox(
height: 20,
child: Tooltip(
message: 'Replies',
child: InkResponse(
radius: Theming.radiusSmall.x,
onTap: () => context.push(Routes.comment(comment.id)),
child: const Icon(Icons.reply_all_rounded, size: Theming.iconSmall),
),
),
),
Tooltip(
message: 'Likes',
triggerMode: .tap,
child: Row(
mainAxisSize: .min,
children: [
Text(
comment.likeCount.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(width: 5),
Icon(Icons.favorite_outline_rounded, size: Theming.iconSmall),
],
),
),
],
],
),
),
if (comment.childComments.isNotEmpty)
depth < _maxCommentDepth
? Column(
spacing: Theming.offset,
mainAxisSize: .min,
children: comment.childComments
.map(
(c) => CommentTile(
c,
viewerId: viewerId,
highContrast: highContrast,
analogClock: analogClock,
interaction: interaction,
depth: depth + 1,
),
)
.toList(),
)
: TextButton(
onPressed: () => context.push(Routes.comment(comment.id)),
child: Text(
comment.childComments.length > 1
? '${comment.childComments.length} replies'
: '1 reply',
),
),
],
),
);
return Column(
spacing: Theming.offset,
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
userRow,
if (highContrast)
Card.outlined(child: contentColumn)
else
Card(
color: depth % 2 == 0 ? null : ColorScheme.of(context).surfaceContainerHigh,
child: contentColumn,
),
],
);
}
}
class _LikeButton extends StatefulWidget {
const _LikeButton(this.comment, this.toggleLike);
final Comment comment;
final Future Function(int commentId) toggleLike;
@override
State<_LikeButton> createState() => __LikeButtonState();
}
class __LikeButtonState extends State<_LikeButton> {
@override
Widget build(BuildContext context) {
final comment = widget.comment;
return Tooltip(
message: !comment.isLiked ? 'Like' : 'Unlike',
child: InkResponse(
radius: Theming.radiusSmall.x,
onTap: () async {
final prevIsLiked = comment.isLiked;
final prevLikeCount = comment.likeCount;
setState(() {
comment.isLiked = !prevIsLiked;
comment.likeCount = prevLikeCount + 1;
});
final err = await widget.toggleLike(comment.id);
if (err == null) return;
setState(() {
comment.isLiked = prevIsLiked;
comment.likeCount = prevLikeCount;
});
if (context.mounted) {
SnackBarExtension.show(context, err.toString());
}
},
child: Row(
children: [
Text(
comment.likeCount.toString(),
style: !comment.isLiked
? TextTheme.of(context).labelSmall
: TextTheme.of(
context,
).labelSmall!.copyWith(color: ColorScheme.of(context).primary),
),
const SizedBox(width: 5),
Icon(
!comment.isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded,
size: Theming.iconSmall,
color: comment.isLiked ? ColorScheme.of(context).primary : null,
),
],
),
),
);
}
}
================================================
FILE: lib/feature/comment/comment_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/feature/comment/comment_model.dart';
import 'package:otraku/feature/comment/comment_provider.dart';
import 'package:otraku/feature/comment/comment_tile.dart';
import 'package:otraku/feature/composition/composition_model.dart';
import 'package:otraku/feature/composition/composition_view.dart';
import 'package:otraku/feature/viewer/persistence_provider.dart';
import 'package:otraku/util/routes.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/dialogs.dart';
import 'package:otraku/widget/layout/adaptive_scaffold.dart';
import 'package:otraku/widget/layout/constrained_view.dart';
import 'package:otraku/widget/layout/hiding_floating_action_button.dart';
import 'package:otraku/widget/layout/top_bar.dart';
import 'package:otraku/widget/loaders.dart';
import 'package:otraku/widget/sheets.dart';
class CommentView extends ConsumerStatefulWidget {
const CommentView(this.id);
final int id;
@override
ConsumerState createState() => _CommentViewState();
}
class _CommentViewState extends ConsumerState {
final _scrollCtrl = ScrollController();
@override
void dispose() {
_scrollCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
ref.listen(
commentProvider(widget.id),
(_, s) =>
s.whenOrNull(error: (error, _) => SnackBarExtension.show(context, error.toString())),
);
final comment = ref.watch(commentProvider(widget.id));
final viewerId = ref.watch(viewerIdProvider);
final options = ref.watch(persistenceProvider.select((s) => s.options));
TopBar? topBar;
void Function()? floatingActionOnPressed;
if (comment.hasValue) {
final value = comment.value!;
topBar = TopBar(trailing: _topBarTrailingContent(context, ref, value, viewerId));
floatingActionOnPressed = () => showSheet(
context,
CompositionView(
tag: CommentCompositionTag(threadId: value.threadId, parentCommentId: value.id),
onSaved: (map) =>
ref.read(commentProvider(widget.id).notifier).appendComment(map, value.id),
),
);
}
return AdaptiveScaffold(
topBar: topBar ?? const TopBar(),
floatingAction: HidingFloatingActionButton(
key: const Key('Reply'),
scrollCtrl: _scrollCtrl,
child: FloatingActionButton(
tooltip: 'New Reply',
onPressed: floatingActionOnPressed,
child: const Icon(Icons.edit_outlined),
),
),
child: ConstrainedView(
child: switch (comment.unwrapPrevious()) {
AsyncData(:final value) => _Content(
ref,
value,
options.highContrast,
options.analogClock,
),
AsyncError() => CustomScrollView(
physics: Theming.bouncyPhysics,
slivers: [
SliverRefreshControl(onRefresh: () => ref.invalidate(commentProvider(widget.id))),
const SliverFillRemaining(child: Center(child: Text('Failed to load'))),
],
),
AsyncLoading() => const Center(child: Loader()),
},
),
);
}
List _topBarTrailingContent(
BuildContext context,
WidgetRef ref,
Comment comment,
int? viewerId,
) => [
const Spacer(),
IconButton(
tooltip: 'More',
icon: const Icon(Ionicons.ellipsis_horizontal),
onPressed: () => showSheet(
context,
SimpleSheet.link(
context,
comment.siteUrl,
viewerId == comment.userId
? [
ListTile(
title: const Text('Edit'),
leading: const Icon(Icons.edit_outlined),
onTap: () => showSheet(
context,
CompositionView(
tag: CommentCompositionTag.edit(id: comment.id, threadId: comment.threadId),
onSaved: (map) {
ref.read(commentProvider(widget.id).notifier).edit(map);
Navigator.pop(context);
},
),
),
),
ListTile(
title: const Text('Delete'),
leading: const Icon(Ionicons.trash_outline),
onTap: () {
Navigator.pop(context);
ConfirmationDialog.show(
context,
title: 'Delete?',
primaryAction: 'Yes',
secondaryAction: 'No',
onConfirm: () async {
final err = await ref.read(commentProvider(widget.id).notifier).delete();
if (!context.mounted) return;
if (err == null) {
Navigator.pop(context);
return;
}
SnackBarExtension.show(context, 'Failed deleting comment: $err');
},
);
},
),
]
: const [],
),
),
),
];
}
class _Content extends StatelessWidget {
const _Content(this.ref, this.comment, this.highContrast, this.analogClock);
final WidgetRef ref;
final Comment comment;
final bool highContrast;
final bool analogClock;
@override
Widget build(BuildContext context) {
final openThread = () => context.push(Routes.thread(comment.threadId));
return CustomScrollView(
physics: Theming.bouncyPhysics,
slivers: [
SliverRefreshControl(onRefresh: () => ref.invalidate(commentProvider(comment.id))),
SliverToBoxAdapter(
child: Semantics(
onTap: openThread,
onTapHint: 'open thread',
child: GestureDetector(
onTap: openThread,
behavior: .opaque,
child: Text(comment.threadTitle, style: TextTheme.of(context).bodyMedium),
),
),
),
SliverToBoxAdapter(child: SizedBox(height: Theming.offset)),
SliverToBoxAdapter(
child: CommentTile(
comment,
viewerId: ref.watch(viewerIdProvider),
highContrast: highContrast,
analogClock: analogClock,
interaction: (
onReplySaved: (map, commentId) =>
ref.read(commentProvider(comment.id).notifier).appendComment(map, commentId),
toggleLike: (commentId) =>
ref.read(commentProvider(comment.id).notifier).toggleCommentLike(commentId),
),
),
),
const SliverFooter(),
],
);
}
}
================================================
FILE: lib/feature/composition/composition_model.dart
================================================
/// Each type of composition is represented by a different tag class that
/// extends [CompositionTag]. All tags must implement equals and hash for
/// riverpod to work correctly.
sealed class CompositionTag {
const CompositionTag({required this.id});
final int? id;
}
class StatusActivityCompositionTag extends CompositionTag {
const StatusActivityCompositionTag({required super.id});
@override
bool operator ==(Object other) => other is StatusActivityCompositionTag && id == other.id;
@override
int get hashCode => id.hashCode;
}
class MessageActivityCompositionTag extends CompositionTag {
const MessageActivityCompositionTag({required super.id, required this.recipientId});
final int recipientId;
@override
bool operator ==(Object other) =>
other is MessageActivityCompositionTag && id == other.id && recipientId == other.recipientId;
@override
int get hashCode => Object.hash(id, recipientId);
}
class ActivityReplyCompositionTag extends CompositionTag {
const ActivityReplyCompositionTag({required super.id, required this.activityId});
final int activityId;
@override
bool operator ==(Object other) =>
other is ActivityReplyCompositionTag && id == other.id && activityId == other.activityId;
@override
int get hashCode => Object.hash(id, activityId);
}
class CommentCompositionTag extends CompositionTag {
const CommentCompositionTag({required this.threadId, required this.parentCommentId})
: super(id: null);
const CommentCompositionTag.edit({required super.id, required this.threadId})
: parentCommentId = null;
final int threadId;
final int? parentCommentId;
@override
bool operator ==(Object other) =>
other is CommentCompositionTag &&
id == other.id &&
threadId == other.threadId &&
parentCommentId == other.parentCommentId;
@override
int get hashCode => Object.hash(id, threadId, parentCommentId);
}
class Composition {
Composition(this.text);
String text;
}
/// Only used for new message activities, since the user can toggle visibility.
class PrivateComposition extends Composition {
PrivateComposition(super.text, this.isPrivate);
bool isPrivate;
}
================================================
FILE: lib/feature/composition/composition_provider.dart
================================================
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:otraku/extension/string_extension.dart';
import 'package:otraku/util/graphql.dart';
import 'package:otraku/feature/composition/composition_model.dart';
import 'package:otraku/feature/viewer/repository_provider.dart';
final compositionProvider = AsyncNotifierProvider.autoDispose
.family(CompositionNotifier.new);
class CompositionNotifier extends AsyncNotifier {
CompositionNotifier(this.arg);
final CompositionTag arg;
@override
FutureOr build() {
if (arg.id == null) {
return switch (arg) {
MessageActivityCompositionTag _ => PrivateComposition('', false),
_ => Composition(''),
};
}
return switch (arg) {
StatusActivityCompositionTag(id: var id) =>
ref
.read(repositoryProvider)
.request(GqlQuery.activityComposition, {'id': id})
.then((data) => Composition(data['Activity']['text'])),
MessageActivityCompositionTag(id: var id) =>
ref
.read(repositoryProvider)
.request(GqlQuery.activityComposition, {'id': id})
.then((data) => Composition(data['Activity']['message'])),
ActivityReplyCompositionTag(id: var id) =>
ref
.read(repositoryProvider)
.request(GqlQuery.activityReplyComposition, {'id': id})
.then((data) => Composition(data['ActivityReply']['text'])),
CommentCompositionTag(id: var id) =>
ref
.read(repositoryProvider)
.request(GqlQuery.commentComposition, {'id': id})
.then((data) => Composition(_findComment(data['ThreadComment'][0]))),
};
}
/// The API always returns the root comment,
/// so we search for the target comment with DFS.
String _findComment(Map map) {
if (map['id'] == arg.id) {
return map['comment'] ?? '';
}
for (final c in map['childComments'] ?? const []) {
final comment = _findComment(c);
if (comment != '') return comment;
}
return '';
}
Future>> save() async {
final value = state.value;
if (value == null) return const AsyncValue.loading();
return AsyncValue.guard(() async {
switch (arg) {
case StatusActivityCompositionTag(id: var id):
final data = await ref.read(repositoryProvider).request(GqlMutation.saveStatusActivity, {
'id': ?id,
'text': value.text.withParsedEmojis,
});
return data['SaveTextActivity'];
case MessageActivityCompositionTag(id: var id, recipientId: var rcpId):
final data = await ref.read(repositoryProvider).request(GqlMutation.saveMessageActivity, {
'id': ?id,
'text': value.text.withParsedEmojis,
'recipientId': rcpId,
if (value is PrivateComposition) 'isPrivate': value.isPrivate,
});
return data['SaveMessageActivity'];
case ActivityReplyCompositionTag(id: var id, activityId: var actId):
final data = await ref.read(repositoryProvider).request(GqlMutation.saveActivityReply, {
'id': ?id,
'text': value.text.withParsedEmojis,
'activityId': actId,
});
return data['SaveActivityReply'];
case CommentCompositionTag(
id: var id,
threadId: var threadId,
parentCommentId: var parentCommentId,
):
final data = await ref.read(repositoryProvider).request(GqlMutation.saveComment, {
'id': ?id,
'text': value.text.withParsedEmojis,
'threadId': threadId,
'parentCommentId': ?parentCommentId,
});
return data['SaveThreadComment'];
}
});
}
}
================================================
FILE: lib/feature/composition/composition_view.dart
================================================
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ionicons/ionicons.dart';
import 'package:otraku/util/markdown.dart';
import 'package:otraku/feature/composition/composition_model.dart';
import 'package:otraku/util/theming.dart';
import 'package:otraku/widget/html_content.dart';
import 'package:otraku/widget/layout/navigation_tool.dart';
import 'package:otraku/widget/loaders.dart';
import 'package:otraku/widget/sheets.dart';
import 'package:otraku/extension/snack_bar_extension.dart';
import 'package:otraku/feature/composition/composition_provider.dart';
class CompositionView extends StatelessWidget {
const CompositionView({required this.tag, required this.onSaved, this.defaultText = ''});
final CompositionTag tag;
final String defaultText;
/// When the edit is saved, a map with the new data is passed back
/// to get deserialized.
final void Function(Map) onSaved;
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, _) {
return ref
.watch(compositionProvider(tag))
.when(
loading: () => SheetWithButtonRow(
builder: (context, scrollCtrl) => const Center(child: Loader()),
),
error: (_, _) => SheetWithButtonRow(
builder: (context, scrollCtrl) => const Center(child: Text('Failed Loading')),
),
data: (data) {
if (data.text.isEmpty) {
data.text = defaultText;
}
return _CompositionView(
composition: data,
trySave: () async {
final result = await ref.read(compositionProvider(tag).notifier).save();
return result.maybeWhen(
data: (data) {
onSaved(result.value!);
Navigator.pop(context);
return true;
},
orElse: () => false,
);
},
);
},
);
},
);
}
}
class _CompositionView extends StatefulWidget {
const _CompositionView({required this.composition, required this.trySave});
final Composition composition;
final Future Function() trySave;
@override
State<_CompositionView> createState() => __CompositionViewState();
}
class __CompositionViewState extends State<_CompositionView> with SingleTickerProviderStateMixin {
late final _textCtrl = TextEditingController(text: widget.composition.text);
late final _tabCtrl = TabController(length: 2, vsync: this);
String _parsedText = '';
final _focus = FocusNode();
@override
void initState() {
super.initState();
_tabCtrl.addListener(() {
setState(() {});
if (_tabCtrl.index == 0) {
_focus.requestFocus();
} else {
_focus.unfocus();
_parsedText = parseMarkdown(_textCtrl.text);
}
});
}
@override
void dispose() {
_tabCtrl.dispose();
_textCtrl.dispose();
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SheetWithButtonRow(
builder: (context, scrollCtrl) => _CompositionBody(
focus: _focus,
tabCtrl: _tabCtrl,
textCtrl: _textCtrl,
scrollCtrl: scrollCtrl,
parsedText: _parsedText,
),
buttons: _BottomBar(
composition: widget.composition,
textCtrl: _textCtrl,
isEditing: _tabCtrl.index == 0,
trySave: widget.trySave,
),
);
}
}
class _CompositionBody extends StatelessWidget {
const _CompositionBody({
required this.focus,
required this.tabCtrl,
required this.textCtrl,
required this.scrollCtrl,
required this.parsedText,
});
final FocusNode focus;
final TabController tabCtrl;
final TextEditingController textCtrl;
final ScrollController scrollCtrl;
final String parsedText;
@override
Widget build(BuildContext context) {
final padding = EdgeInsets.only(
left: 20,
right: 20,
top: 60,
bottom: MediaQuery.paddingOf(context).bottom + Theming.offset,
);
return Stack(
children: [
TabBarView(
controller: tabCtrl,
children: [
SingleChildScrollView(
controller: scrollCtrl,
child: TextField(
autofocus: true,
focusNode: focus,
controller: textCtrl,
style: TextTheme.of(context).bodyMedium,
decoration: InputDecoration(contentPadding: padding),
maxLines: null,
),
),
SingleChildScrollView(
controller: scrollCtrl,
child: Padding(padding: padding, child: HtmlContent(parsedText)),
),
],
),
Positioned(
top: 0,
left: 0,
right: 0,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Theming.radiusBig),
child: BackdropFilter(
filter: Theming.blurFilter,
child: Container(
padding: Theming.paddingAll,
color: Theme.of(context).navigationBarTheme.backgroundColor,
child: SegmentedButton(
segments: const [
ButtonSegment(
value: 0,
label: Text('Compose'),
icon: Icon(Icons.edit_outlined),
),
ButtonSegment(
value: 1,
label: Text('Preview'),
icon: Icon(Icons.preview_outlined),
),
],
selected: {tabCtrl.index},
onSelectionChanged: (i) => tabCtrl.index = i.first,
),
),
),
),
),
],
);
}
}
/// A button menu. Some of the buttons are hidden,
/// when the user isn't on the editing tab.
class _BottomBar extends StatefulWidget {
const _BottomBar({
required this.composition,
required this.isEditing,
required this.textCtrl,
required this.trySave,
});
final Composition composition;
final bool isEditing;
final TextEditingController textCtrl;
final Future Function() trySave;
@override
State<_BottomBar> createState() => _BottomBarState();
}
class _BottomBarState extends State<_BottomBar> {
bool _locked = false;
@override
Widget build(BuildContext context) {
return BottomBar([
if (widget.isEditing) ...[
Expanded(
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_FormatButton(
startDelimiter: '**',
endDelimiter: '**',
name: 'Bold',
icon: Icons.format_bold_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: '*',
endDelimiter: '*',
name: 'Italic',
icon: Icons.format_italic_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: '~~',
endDelimiter: '~~',
name: 'Strikethrough',
icon: Icons.format_strikethrough_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: '~!',
endDelimiter: '!~',
name: 'Spoiler',
icon: Icons.hide_image_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: '[',
endDelimiter: ']()',
name: 'Link',
icon: Icons.link_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: 'img(',
endDelimiter: ')',
name: 'Image',
icon: Icons.image_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: 'youtube(',
endDelimiter: ')',
name: 'YouTube Video',
icon: Icons.video_collection_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: 'webm(',
endDelimiter: ')',
name: 'WebM Video',
icon: Icons.videocam_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: '~~~',
endDelimiter: '~~~',
name: 'Center',
icon: Icons.align_horizontal_center_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: '# ',
endDelimiter: '',
name: 'Header',
icon: Icons.title_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: '> ',
endDelimiter: '',
name: 'Quote',
icon: Icons.format_quote_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: '`',
endDelimiter: '`',
name: 'Code',
icon: Icons.code_outlined,
textCtrl: widget.textCtrl,
),
_FormatButton(
startDelimiter: '```',
endDelimiter: '```',
name: 'Code Block',
icon: Icons.code_off_outlined,
textCtrl: widget.textCtrl,
),
],
),
),
Container(
width: 3,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: ColorScheme.of(context).outline,
),
),
] else
const Spacer(),
if (widget.composition is PrivateComposition)
_PrivateButton(widget.composition as PrivateComposition),
IconButton(
tooltip: 'Post',
icon: const Icon(Ionicons.send_outline),
onPressed: _locked
? null
: () async {
setState(() => _locked = true);
widget.composition.text = widget.textCtrl.text;
if (await widget.trySave()) return;
setState(() => _locked = false);
if (context.mounted) {
SnackBarExtension.show(context, 'Failed to save');
}
},
),
]);
}
}
/// Encloses the current text selection in a given markdown tag.
class _FormatButton extends StatelessWidget {
const _FormatButton({
required this.startDelimiter,
required this.endDelimiter,
required this.name,
required this.icon,
required this.textCtrl,
});
final String startDelimiter;
final String endDelimiter;
final String name;
final IconData icon;
final TextEditingController textCtrl;
@override
Widget build(BuildContext context) => IconButton(
tooltip: name,
icon: Icon(icon),
onPressed: () {
final txt = textCtrl.text;
final beg = textCtrl.selection.start;
final end = textCtrl.selection.end;
if (beg < 0) return;
final text =
'${txt.substring(0, beg)}'
'$startDelimiter'
'${txt.substring(beg, end)}'
'$endDelimiter'
'${txt.substring(end)}';
final offset = textCtrl.selection.isCollapsed
? textCtrl.selection.end + startDelimiter.length
: textCtrl.selection.end + startDelimiter.length + endDelimiter.length;
textCtrl.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: offset),
);
},
);
}
/// Controls whether a message will be created as private or public.
class _PrivateButton extends StatefulWidget {
const _PrivateButton(this.composition);
final PrivateComposition composition;
@override
State<_PrivateButton> createState() => __PrivateButtonState();
}
class __PrivateButtonState extends State<_PrivateButton> {
@override
Widget build(BuildContext context) => IconButton(
tooltip: widget.composition.isPrivate ? 'Make Public' : 'Make Private',
icon: widget.composition.isPrivate
? const Icon(Ionicons.eye_outline)
: const Icon(Ionicons.eye_off_outline),
onPressed: () {
setState(() => widget.composition.isPrivate = !widget.composition.isPrivate);
SnackBarExtension.show(
context,
widget.composition.isPrivate ? 'Message is now private' : 'Message is now public',
);
},
);
}
================================================
FILE: lib/feature/discover/discover_filter_model.dart
================================================
import 'package:otraku/extension/enum_extension.dart';
import 'package:otraku/feature/collection/collection_filter_model.dart';
import 'package:otraku/feature/discover/discover_model.dart';
import 'package:otraku/feature/media/media_models.dart';
import 'package:otraku/feature/review/review_models.dart';
class DiscoverFilter {
const DiscoverFilter._({
required this.type,
required this.search,
required this.mediaFilter,
required this.hasBirthday,
required this.reviewsFilter,
required this.recommendationsFilter,
});
DiscoverFilter(this.type, this.mediaFilter)
: search = '',
hasBirthday = false,
reviewsFilter = const ReviewsFilter(),
recommendationsFilter = const DiscoverRecommendationsFilter();
final DiscoverType type;
final String search;
final DiscoverMediaFilter mediaFilter;
final bool hasBirthday;
final ReviewsFilter reviewsFilter;
final DiscoverRecommendationsFilter recommendationsFilter;
DiscoverFilter copyWith({
DiscoverType? type,
String? search,
DiscoverMediaFilter? mediaFilter,
bool? hasBirthday,
ReviewsFilter? reviewsFilter,
DiscoverRecommendationsFilter? recommendationsFilter,
}) => DiscoverFilter._(
type: type ?? this.type,
search: search ?? this.search,
mediaFilter: mediaFilter ?? this.mediaFilter,
hasBirthday: hasBirthday ?? this.hasBirthday,
reviewsFilter: reviewsFilter ?? this.reviewsFilter,
recommendationsFilter: recommendationsFilter ?? this.recommendationsFilter,
);
}
class DiscoverMediaFilter {
DiscoverMediaFilter(this.sort);
factory DiscoverMediaFilter.fromPersistenceMap(Map map) {
final sort = MediaSort.values.getOrFirst(map['sort']);
final filter = DiscoverMediaFilter(sort)
..season = MediaSeason.values.getOrNull(map['season'])
..startYearFrom = map['startYearFrom']
..startYearTo = map['startYearTo']
..country = OriginCountry.values.getOrNull(map['country'])
..inLists = map['inLists']
..isAdult = map['isAdult']
..isLicensed = map['isLicensed'];
for (final e in map['statuses'] ?? const []) {
final status = ReleaseStatus.values.getOrNull(e);
if (status != null) {
filter.statuses.add(status);
}
}
for (final e in map['animeFormats'] ?? const []) {
final format = MediaFormat.values.getOrNull(e);
if (format != null) {
filter.animeFormats.add(format);
}
}
for (final e in map['mangaFormats'] ?? const []) {
final format = MediaFormat.values.getOrNull(e);
if (format != null) {
filter.mangaFormats.add(format);
}
}
for (final e in map['sources'] ?? const []) {
final source = MediaSource.values.getOrNull(e);
if (source != null) {
filter.sources.add(source);
}
}
filter.genreIn.addAll(map['genreIn'] ?? const []);
filter.genreNotIn.addAll(map['genreNotIn'] ?? const []);
filter.tagIn.addAll(map['tagIn'] ?? const []);
filter.tagNotIn.addAll(map['tagNotIn'] ?? const []);
return filter;
}
final statuses =