├── .github └── workflows │ └── push.yaml ├── .gitignore ├── .metadata ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── feather │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable-v21 │ │ │ └── launch_background.xml │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── values-night │ │ │ └── styles.xml │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── fonts │ └── Urbanist-Regular.ttf └── images │ └── feathr-icon.png ├── ios ├── .gitignore ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-1024x1024@1x.png │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ └── Icon-App-83.5x83.5@2x.png │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ ├── LaunchImage.png │ │ ├── LaunchImage@2x.png │ │ ├── LaunchImage@3x.png │ │ └── README.md │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ └── Runner-Bridging-Header.h ├── lib ├── app.dart ├── data │ ├── account.dart │ ├── custom_emoji.dart │ └── status.dart ├── helpers │ └── auth.dart ├── main.dart ├── screens │ ├── about.dart │ ├── login.dart │ ├── timeline_tabs.dart │ └── user.dart ├── services │ └── api.dart ├── themes │ ├── dark.dart │ └── light.dart ├── utils │ └── messages.dart └── widgets │ ├── buttons.dart │ ├── drawer.dart │ ├── instance_form.dart │ ├── status_card.dart │ ├── status_form.dart │ ├── timeline.dart │ └── title.dart ├── pubspec.lock ├── pubspec.yaml └── test ├── app_test.dart ├── data_test ├── account_test.dart ├── custom_emoji_test.dart └── status_test.dart ├── screens_test ├── about_test.dart ├── login_test.dart ├── tabs_test.dart └── user_test.dart ├── services_test ├── api_test.dart └── api_test.mocks.dart ├── utils.dart ├── utils_test └── messages_test.dart └── widgets_test ├── buttons_test.dart ├── drawer_test.dart ├── instance_form_test.dart ├── status_card_test.dart ├── status_form_test.dart └── title_test.dart /.github/workflows/push.yaml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Tests 3 | jobs: 4 | test: 5 | name: Set up project and run tests 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: actions/setup-java@v4 10 | with: 11 | distribution: 'zulu' 12 | java-version: '21' 13 | - uses: subosito/flutter-action@v2 14 | with: 15 | flutter-version: '3.29.3' 16 | channel: 'stable' 17 | - name: Download dependencies 18 | run: flutter pub get 19 | - name: Analyze code 20 | run: flutter analyze 21 | - name: Verify if code is formatted properly 22 | run: dart format -o show --set-exit-if-changed . 23 | - name: Run tests 24 | run: flutter test --coverage --reporter expanded 25 | - name: Print coverage information 26 | run: dart run test_cov_console --file=coverage/lcov.info 27 | - name: Ensure test coverage is at least 70% 28 | run: | 29 | result=$(dart run test_cov_console --file=coverage/lcov.info --pass=70) 30 | echo $result 31 | if [ "$result" = "PASSED" ]; then 32 | exit 0 33 | else 34 | exit 1 35 | fi 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Web related 35 | lib/generated_plugin_registrant.dart 36 | 37 | # Symbolication related 38 | app.*.symbols 39 | 40 | # Obfuscation related 41 | app.*.map.json 42 | 43 | # Android Studio will place build artifacts here 44 | /android/app/debug 45 | /android/app/profile 46 | /android/app/release 47 | 48 | # Coverage files 49 | coverage/ 50 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 18116933e77adc82f80866c928266a5b4f1ed645 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: dart-format 5 | language: system 6 | name: Format Dart Code 7 | entry: dart format --output write --set-exit-if-changed 8 | files: lib/|test/ 9 | - id: flutter-analyze 10 | language: system 11 | name: Analyze Flutter Code 12 | entry: flutter analyze 13 | files: lib/|test/ 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feathr 2 | 3 | ![Tests badge](https://github.com/feathr-space/feathr/actions/workflows/push.yaml/badge.svg) 4 | 5 |

6 | 7 |

8 | 9 | A Mastodon client built in Flutter (in development). 10 | 11 | ## Contributing 12 | 13 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 14 | 15 | Please make sure to update tests as appropriate. 16 | 17 | ## License 18 | 19 | This project is licensed under the [GNU Affero General Public License](/LICENSE). 20 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | **/*.keystore 13 | **/*.jks 14 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | 28 | android { 29 | compileSdkVersion 34 30 | 31 | compileOptions { 32 | sourceCompatibility JavaVersion.VERSION_1_8 33 | targetCompatibility JavaVersion.VERSION_1_8 34 | } 35 | 36 | kotlinOptions { 37 | jvmTarget = '1.8' 38 | } 39 | 40 | sourceSets { 41 | main.java.srcDirs += 'src/main/kotlin' 42 | } 43 | 44 | defaultConfig { 45 | applicationId "space.feathr.app" 46 | minSdkVersion 23 47 | targetSdkVersion 33 48 | versionCode flutterVersionCode.toInteger() 49 | versionName flutterVersionName 50 | manifestPlaceholders = [ 51 | 'appAuthRedirectScheme': 'space.feathr.app' 52 | ] 53 | } 54 | 55 | buildTypes { 56 | release { 57 | // TODO: Add your own signing config for the release build. 58 | // Signing with the debug keys for now, so `flutter run --release` works. 59 | signingConfig signingConfigs.debug 60 | } 61 | } 62 | } 63 | 64 | flutter { 65 | source '../..' 66 | } 67 | 68 | dependencies { 69 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 70 | } 71 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 25 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/feather/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package space.feathr.app 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.3.50' 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:4.1.0' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | project.evaluationDependsOn(':app') 25 | } 26 | 27 | task clean(type: Delete) { 28 | delete rootProject.buildDir 29 | } 30 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.daemon=true 2 | org.gradle.parallel=true 3 | org.gradle.jvmargs=-Xmx1536M 4 | android.useAndroidX=true 5 | android.enableJetifier=true -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /assets/fonts/Urbanist-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/assets/fonts/Urbanist-Regular.ttf -------------------------------------------------------------------------------- /assets/images/feathr-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/assets/images/feathr-icon.png -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 12.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | platform :ios, '12.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | end 36 | 37 | post_install do |installer| 38 | installer.pods_project.targets.each do |target| 39 | flutter_additional_ios_build_settings(target) 40 | target.build_configurations.each do |config| 41 | config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 54; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 11 | 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 12 | 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 13 | 7F810197251B3EAD579E3CFB /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 13F0225BD37C3C9E79C4A654 /* Pods_Runner.framework */; }; 14 | 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 15 | 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 16 | 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXCopyFilesBuildPhase section */ 20 | 9705A1C41CF9048500538489 /* Embed Frameworks */ = { 21 | isa = PBXCopyFilesBuildPhase; 22 | buildActionMask = 2147483647; 23 | dstPath = ""; 24 | dstSubfolderSpec = 10; 25 | files = ( 26 | ); 27 | name = "Embed Frameworks"; 28 | runOnlyForDeploymentPostprocessing = 0; 29 | }; 30 | /* End PBXCopyFilesBuildPhase section */ 31 | 32 | /* Begin PBXFileReference section */ 33 | 13F0225BD37C3C9E79C4A654 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 34 | 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 35 | 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 36 | 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 37 | 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 38 | 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 39 | 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 40 | 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 41 | 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 42 | 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 43 | 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 44 | 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 45 | 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 46 | 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 47 | BBB71F05DBF430E3C07074A1 /* 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 = ""; }; 48 | C993DD96358A4A5EBC9558FC /* 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 = ""; }; 49 | E323D48652837438D964C226 /* 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 = ""; }; 50 | /* End PBXFileReference section */ 51 | 52 | /* Begin PBXFrameworksBuildPhase section */ 53 | 97C146EB1CF9000F007C117D /* Frameworks */ = { 54 | isa = PBXFrameworksBuildPhase; 55 | buildActionMask = 2147483647; 56 | files = ( 57 | 7F810197251B3EAD579E3CFB /* Pods_Runner.framework in Frameworks */, 58 | ); 59 | runOnlyForDeploymentPostprocessing = 0; 60 | }; 61 | /* End PBXFrameworksBuildPhase section */ 62 | 63 | /* Begin PBXGroup section */ 64 | 79C9C932D697FE8CF08F9F72 /* Frameworks */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | 13F0225BD37C3C9E79C4A654 /* Pods_Runner.framework */, 68 | ); 69 | name = Frameworks; 70 | sourceTree = ""; 71 | }; 72 | 7DC4628BCFB091811AAB9288 /* Pods */ = { 73 | isa = PBXGroup; 74 | children = ( 75 | C993DD96358A4A5EBC9558FC /* Pods-Runner.debug.xcconfig */, 76 | E323D48652837438D964C226 /* Pods-Runner.release.xcconfig */, 77 | BBB71F05DBF430E3C07074A1 /* Pods-Runner.profile.xcconfig */, 78 | ); 79 | path = Pods; 80 | sourceTree = ""; 81 | }; 82 | 9740EEB11CF90186004384FC /* Flutter */ = { 83 | isa = PBXGroup; 84 | children = ( 85 | 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 86 | 9740EEB21CF90195004384FC /* Debug.xcconfig */, 87 | 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 88 | 9740EEB31CF90195004384FC /* Generated.xcconfig */, 89 | ); 90 | name = Flutter; 91 | sourceTree = ""; 92 | }; 93 | 97C146E51CF9000F007C117D = { 94 | isa = PBXGroup; 95 | children = ( 96 | 9740EEB11CF90186004384FC /* Flutter */, 97 | 97C146F01CF9000F007C117D /* Runner */, 98 | 97C146EF1CF9000F007C117D /* Products */, 99 | 7DC4628BCFB091811AAB9288 /* Pods */, 100 | 79C9C932D697FE8CF08F9F72 /* Frameworks */, 101 | ); 102 | sourceTree = ""; 103 | }; 104 | 97C146EF1CF9000F007C117D /* Products */ = { 105 | isa = PBXGroup; 106 | children = ( 107 | 97C146EE1CF9000F007C117D /* Runner.app */, 108 | ); 109 | name = Products; 110 | sourceTree = ""; 111 | }; 112 | 97C146F01CF9000F007C117D /* Runner */ = { 113 | isa = PBXGroup; 114 | children = ( 115 | 97C146FA1CF9000F007C117D /* Main.storyboard */, 116 | 97C146FD1CF9000F007C117D /* Assets.xcassets */, 117 | 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 118 | 97C147021CF9000F007C117D /* Info.plist */, 119 | 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 120 | 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 121 | 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 122 | 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 123 | ); 124 | path = Runner; 125 | sourceTree = ""; 126 | }; 127 | /* End PBXGroup section */ 128 | 129 | /* Begin PBXNativeTarget section */ 130 | 97C146ED1CF9000F007C117D /* Runner */ = { 131 | isa = PBXNativeTarget; 132 | buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; 133 | buildPhases = ( 134 | 20DF13B641D2554580728DEA /* [CP] Check Pods Manifest.lock */, 135 | 9740EEB61CF901F6004384FC /* Run Script */, 136 | 97C146EA1CF9000F007C117D /* Sources */, 137 | 97C146EB1CF9000F007C117D /* Frameworks */, 138 | 97C146EC1CF9000F007C117D /* Resources */, 139 | 9705A1C41CF9048500538489 /* Embed Frameworks */, 140 | 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 141 | A7D00865B77EFAD7F6B00FC1 /* [CP] Embed Pods Frameworks */, 142 | ); 143 | buildRules = ( 144 | ); 145 | dependencies = ( 146 | ); 147 | name = Runner; 148 | productName = Runner; 149 | productReference = 97C146EE1CF9000F007C117D /* Runner.app */; 150 | productType = "com.apple.product-type.application"; 151 | }; 152 | /* End PBXNativeTarget section */ 153 | 154 | /* Begin PBXProject section */ 155 | 97C146E61CF9000F007C117D /* Project object */ = { 156 | isa = PBXProject; 157 | attributes = { 158 | LastUpgradeCheck = 1510; 159 | ORGANIZATIONNAME = ""; 160 | TargetAttributes = { 161 | 97C146ED1CF9000F007C117D = { 162 | CreatedOnToolsVersion = 7.3.1; 163 | LastSwiftMigration = 1100; 164 | }; 165 | }; 166 | }; 167 | buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; 168 | compatibilityVersion = "Xcode 9.3"; 169 | developmentRegion = en; 170 | hasScannedForEncodings = 0; 171 | knownRegions = ( 172 | en, 173 | Base, 174 | ); 175 | mainGroup = 97C146E51CF9000F007C117D; 176 | productRefGroup = 97C146EF1CF9000F007C117D /* Products */; 177 | projectDirPath = ""; 178 | projectRoot = ""; 179 | targets = ( 180 | 97C146ED1CF9000F007C117D /* Runner */, 181 | ); 182 | }; 183 | /* End PBXProject section */ 184 | 185 | /* Begin PBXResourcesBuildPhase section */ 186 | 97C146EC1CF9000F007C117D /* Resources */ = { 187 | isa = PBXResourcesBuildPhase; 188 | buildActionMask = 2147483647; 189 | files = ( 190 | 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 191 | 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 192 | 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 193 | 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, 194 | ); 195 | runOnlyForDeploymentPostprocessing = 0; 196 | }; 197 | /* End PBXResourcesBuildPhase section */ 198 | 199 | /* Begin PBXShellScriptBuildPhase section */ 200 | 20DF13B641D2554580728DEA /* [CP] Check Pods Manifest.lock */ = { 201 | isa = PBXShellScriptBuildPhase; 202 | buildActionMask = 2147483647; 203 | files = ( 204 | ); 205 | inputFileListPaths = ( 206 | ); 207 | inputPaths = ( 208 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 209 | "${PODS_ROOT}/Manifest.lock", 210 | ); 211 | name = "[CP] Check Pods Manifest.lock"; 212 | outputFileListPaths = ( 213 | ); 214 | outputPaths = ( 215 | "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", 216 | ); 217 | runOnlyForDeploymentPostprocessing = 0; 218 | shellPath = /bin/sh; 219 | 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"; 220 | showEnvVarsInLog = 0; 221 | }; 222 | 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { 223 | isa = PBXShellScriptBuildPhase; 224 | alwaysOutOfDate = 1; 225 | buildActionMask = 2147483647; 226 | files = ( 227 | ); 228 | inputPaths = ( 229 | "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", 230 | ); 231 | name = "Thin Binary"; 232 | outputPaths = ( 233 | ); 234 | runOnlyForDeploymentPostprocessing = 0; 235 | shellPath = /bin/sh; 236 | shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; 237 | }; 238 | 9740EEB61CF901F6004384FC /* Run Script */ = { 239 | isa = PBXShellScriptBuildPhase; 240 | alwaysOutOfDate = 1; 241 | buildActionMask = 2147483647; 242 | files = ( 243 | ); 244 | inputPaths = ( 245 | ); 246 | name = "Run Script"; 247 | outputPaths = ( 248 | ); 249 | runOnlyForDeploymentPostprocessing = 0; 250 | shellPath = /bin/sh; 251 | shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; 252 | }; 253 | A7D00865B77EFAD7F6B00FC1 /* [CP] Embed Pods Frameworks */ = { 254 | isa = PBXShellScriptBuildPhase; 255 | buildActionMask = 2147483647; 256 | files = ( 257 | ); 258 | inputFileListPaths = ( 259 | "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", 260 | ); 261 | name = "[CP] Embed Pods Frameworks"; 262 | outputFileListPaths = ( 263 | "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", 264 | ); 265 | runOnlyForDeploymentPostprocessing = 0; 266 | shellPath = /bin/sh; 267 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; 268 | showEnvVarsInLog = 0; 269 | }; 270 | /* End PBXShellScriptBuildPhase section */ 271 | 272 | /* Begin PBXSourcesBuildPhase section */ 273 | 97C146EA1CF9000F007C117D /* Sources */ = { 274 | isa = PBXSourcesBuildPhase; 275 | buildActionMask = 2147483647; 276 | files = ( 277 | 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 278 | 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 279 | ); 280 | runOnlyForDeploymentPostprocessing = 0; 281 | }; 282 | /* End PBXSourcesBuildPhase section */ 283 | 284 | /* Begin PBXVariantGroup section */ 285 | 97C146FA1CF9000F007C117D /* Main.storyboard */ = { 286 | isa = PBXVariantGroup; 287 | children = ( 288 | 97C146FB1CF9000F007C117D /* Base */, 289 | ); 290 | name = Main.storyboard; 291 | sourceTree = ""; 292 | }; 293 | 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { 294 | isa = PBXVariantGroup; 295 | children = ( 296 | 97C147001CF9000F007C117D /* Base */, 297 | ); 298 | name = LaunchScreen.storyboard; 299 | sourceTree = ""; 300 | }; 301 | /* End PBXVariantGroup section */ 302 | 303 | /* Begin XCBuildConfiguration section */ 304 | 249021D3217E4FDB00AE95B9 /* Profile */ = { 305 | isa = XCBuildConfiguration; 306 | buildSettings = { 307 | ALWAYS_SEARCH_USER_PATHS = NO; 308 | CLANG_ANALYZER_NONNULL = YES; 309 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 310 | CLANG_CXX_LIBRARY = "libc++"; 311 | CLANG_ENABLE_MODULES = YES; 312 | CLANG_ENABLE_OBJC_ARC = YES; 313 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 314 | CLANG_WARN_BOOL_CONVERSION = YES; 315 | CLANG_WARN_COMMA = YES; 316 | CLANG_WARN_CONSTANT_CONVERSION = YES; 317 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 318 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 319 | CLANG_WARN_EMPTY_BODY = YES; 320 | CLANG_WARN_ENUM_CONVERSION = YES; 321 | CLANG_WARN_INFINITE_RECURSION = YES; 322 | CLANG_WARN_INT_CONVERSION = YES; 323 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 324 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 325 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 326 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 327 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 328 | CLANG_WARN_STRICT_PROTOTYPES = YES; 329 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 330 | CLANG_WARN_UNREACHABLE_CODE = YES; 331 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 332 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 333 | COPY_PHASE_STRIP = NO; 334 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 335 | ENABLE_NS_ASSERTIONS = NO; 336 | ENABLE_STRICT_OBJC_MSGSEND = YES; 337 | GCC_C_LANGUAGE_STANDARD = gnu99; 338 | GCC_NO_COMMON_BLOCKS = YES; 339 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 340 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 341 | GCC_WARN_UNDECLARED_SELECTOR = YES; 342 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 343 | GCC_WARN_UNUSED_FUNCTION = YES; 344 | GCC_WARN_UNUSED_VARIABLE = YES; 345 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 346 | MTL_ENABLE_DEBUG_INFO = NO; 347 | SDKROOT = iphoneos; 348 | SUPPORTED_PLATFORMS = iphoneos; 349 | TARGETED_DEVICE_FAMILY = "1,2"; 350 | VALIDATE_PRODUCT = YES; 351 | }; 352 | name = Profile; 353 | }; 354 | 249021D4217E4FDB00AE95B9 /* Profile */ = { 355 | isa = XCBuildConfiguration; 356 | baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; 357 | buildSettings = { 358 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 359 | CLANG_ENABLE_MODULES = YES; 360 | CODE_SIGN_IDENTITY = "Apple Development"; 361 | CODE_SIGN_STYLE = Automatic; 362 | CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 363 | DEVELOPMENT_TEAM = ""; 364 | ENABLE_BITCODE = NO; 365 | INFOPLIST_FILE = Runner/Info.plist; 366 | LD_RUNPATH_SEARCH_PATHS = ( 367 | "$(inherited)", 368 | "@executable_path/Frameworks", 369 | ); 370 | PRODUCT_BUNDLE_IDENTIFIER = space.feathr.app; 371 | PRODUCT_NAME = "$(TARGET_NAME)"; 372 | PROVISIONING_PROFILE_SPECIFIER = ""; 373 | SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; 374 | SWIFT_VERSION = 5.0; 375 | VERSIONING_SYSTEM = "apple-generic"; 376 | }; 377 | name = Profile; 378 | }; 379 | 97C147031CF9000F007C117D /* Debug */ = { 380 | isa = XCBuildConfiguration; 381 | buildSettings = { 382 | ALWAYS_SEARCH_USER_PATHS = NO; 383 | CLANG_ANALYZER_NONNULL = YES; 384 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 385 | CLANG_CXX_LIBRARY = "libc++"; 386 | CLANG_ENABLE_MODULES = YES; 387 | CLANG_ENABLE_OBJC_ARC = YES; 388 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 389 | CLANG_WARN_BOOL_CONVERSION = YES; 390 | CLANG_WARN_COMMA = YES; 391 | CLANG_WARN_CONSTANT_CONVERSION = YES; 392 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 393 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 394 | CLANG_WARN_EMPTY_BODY = YES; 395 | CLANG_WARN_ENUM_CONVERSION = YES; 396 | CLANG_WARN_INFINITE_RECURSION = YES; 397 | CLANG_WARN_INT_CONVERSION = YES; 398 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 399 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 400 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 401 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 402 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 403 | CLANG_WARN_STRICT_PROTOTYPES = YES; 404 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 405 | CLANG_WARN_UNREACHABLE_CODE = YES; 406 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 407 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 408 | COPY_PHASE_STRIP = NO; 409 | DEBUG_INFORMATION_FORMAT = dwarf; 410 | ENABLE_STRICT_OBJC_MSGSEND = YES; 411 | ENABLE_TESTABILITY = YES; 412 | GCC_C_LANGUAGE_STANDARD = gnu99; 413 | GCC_DYNAMIC_NO_PIC = NO; 414 | GCC_NO_COMMON_BLOCKS = YES; 415 | GCC_OPTIMIZATION_LEVEL = 0; 416 | GCC_PREPROCESSOR_DEFINITIONS = ( 417 | "DEBUG=1", 418 | "$(inherited)", 419 | ); 420 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 421 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 422 | GCC_WARN_UNDECLARED_SELECTOR = YES; 423 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 424 | GCC_WARN_UNUSED_FUNCTION = YES; 425 | GCC_WARN_UNUSED_VARIABLE = YES; 426 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 427 | MTL_ENABLE_DEBUG_INFO = YES; 428 | ONLY_ACTIVE_ARCH = YES; 429 | SDKROOT = iphoneos; 430 | TARGETED_DEVICE_FAMILY = "1,2"; 431 | }; 432 | name = Debug; 433 | }; 434 | 97C147041CF9000F007C117D /* Release */ = { 435 | isa = XCBuildConfiguration; 436 | buildSettings = { 437 | ALWAYS_SEARCH_USER_PATHS = NO; 438 | CLANG_ANALYZER_NONNULL = YES; 439 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 440 | CLANG_CXX_LIBRARY = "libc++"; 441 | CLANG_ENABLE_MODULES = YES; 442 | CLANG_ENABLE_OBJC_ARC = YES; 443 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 444 | CLANG_WARN_BOOL_CONVERSION = YES; 445 | CLANG_WARN_COMMA = YES; 446 | CLANG_WARN_CONSTANT_CONVERSION = YES; 447 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 448 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 449 | CLANG_WARN_EMPTY_BODY = YES; 450 | CLANG_WARN_ENUM_CONVERSION = YES; 451 | CLANG_WARN_INFINITE_RECURSION = YES; 452 | CLANG_WARN_INT_CONVERSION = YES; 453 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 454 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 455 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 456 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 457 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 458 | CLANG_WARN_STRICT_PROTOTYPES = YES; 459 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 460 | CLANG_WARN_UNREACHABLE_CODE = YES; 461 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 462 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 463 | COPY_PHASE_STRIP = NO; 464 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 465 | ENABLE_NS_ASSERTIONS = NO; 466 | ENABLE_STRICT_OBJC_MSGSEND = YES; 467 | GCC_C_LANGUAGE_STANDARD = gnu99; 468 | GCC_NO_COMMON_BLOCKS = YES; 469 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 470 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 471 | GCC_WARN_UNDECLARED_SELECTOR = YES; 472 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 473 | GCC_WARN_UNUSED_FUNCTION = YES; 474 | GCC_WARN_UNUSED_VARIABLE = YES; 475 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 476 | MTL_ENABLE_DEBUG_INFO = NO; 477 | SDKROOT = iphoneos; 478 | SUPPORTED_PLATFORMS = iphoneos; 479 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 480 | TARGETED_DEVICE_FAMILY = "1,2"; 481 | VALIDATE_PRODUCT = YES; 482 | }; 483 | name = Release; 484 | }; 485 | 97C147061CF9000F007C117D /* Debug */ = { 486 | isa = XCBuildConfiguration; 487 | baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; 488 | buildSettings = { 489 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 490 | CLANG_ENABLE_MODULES = YES; 491 | CODE_SIGN_IDENTITY = "Apple Development"; 492 | CODE_SIGN_STYLE = Automatic; 493 | CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 494 | DEVELOPMENT_TEAM = ""; 495 | ENABLE_BITCODE = NO; 496 | INFOPLIST_FILE = Runner/Info.plist; 497 | LD_RUNPATH_SEARCH_PATHS = ( 498 | "$(inherited)", 499 | "@executable_path/Frameworks", 500 | ); 501 | PRODUCT_BUNDLE_IDENTIFIER = space.feathr.app; 502 | PRODUCT_NAME = "$(TARGET_NAME)"; 503 | PROVISIONING_PROFILE_SPECIFIER = ""; 504 | SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; 505 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 506 | SWIFT_VERSION = 5.0; 507 | VERSIONING_SYSTEM = "apple-generic"; 508 | }; 509 | name = Debug; 510 | }; 511 | 97C147071CF9000F007C117D /* Release */ = { 512 | isa = XCBuildConfiguration; 513 | baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; 514 | buildSettings = { 515 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 516 | CLANG_ENABLE_MODULES = YES; 517 | CODE_SIGN_IDENTITY = "Apple Development"; 518 | CODE_SIGN_STYLE = Automatic; 519 | CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 520 | DEVELOPMENT_TEAM = ""; 521 | ENABLE_BITCODE = NO; 522 | INFOPLIST_FILE = Runner/Info.plist; 523 | LD_RUNPATH_SEARCH_PATHS = ( 524 | "$(inherited)", 525 | "@executable_path/Frameworks", 526 | ); 527 | PRODUCT_BUNDLE_IDENTIFIER = space.feathr.app; 528 | PRODUCT_NAME = "$(TARGET_NAME)"; 529 | PROVISIONING_PROFILE_SPECIFIER = ""; 530 | SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; 531 | SWIFT_VERSION = 5.0; 532 | VERSIONING_SYSTEM = "apple-generic"; 533 | }; 534 | name = Release; 535 | }; 536 | /* End XCBuildConfiguration section */ 537 | 538 | /* Begin XCConfigurationList section */ 539 | 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { 540 | isa = XCConfigurationList; 541 | buildConfigurations = ( 542 | 97C147031CF9000F007C117D /* Debug */, 543 | 97C147041CF9000F007C117D /* Release */, 544 | 249021D3217E4FDB00AE95B9 /* Profile */, 545 | ); 546 | defaultConfigurationIsVisible = 0; 547 | defaultConfigurationName = Release; 548 | }; 549 | 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { 550 | isa = XCConfigurationList; 551 | buildConfigurations = ( 552 | 97C147061CF9000F007C117D /* Debug */, 553 | 97C147071CF9000F007C117D /* Release */, 554 | 249021D4217E4FDB00AE95B9 /* Profile */, 555 | ); 556 | defaultConfigurationIsVisible = 0; 557 | defaultConfigurationName = Release; 558 | }; 559 | /* End XCConfigurationList section */ 560 | }; 561 | rootObject = 97C146E61CF9000F007C117D /* Project object */; 562 | } 563 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @main 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathr-space/feathr/756dcd12986d3082289799bec4765fa144252588/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | feathr 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(FLUTTER_BUILD_NAME) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UIViewControllerBasedStatusBarAppearance 43 | 44 | CFBundleURLTypes 45 | 46 | 47 | CFBundleTypeRole 48 | Editor 49 | CFBundleURLSchemes 50 | 51 | space.feathr.app 52 | 53 | 54 | 55 | LSApplicationQueriesSchemes 56 | 57 | https 58 | http 59 | 60 | CADisableMinimumFrameDurationOnPhone 61 | 62 | UIApplicationSupportsIndirectInputEvents 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /lib/app.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'package:feathr/themes/dark.dart'; 4 | import 'package:feathr/themes/light.dart'; 5 | 6 | import 'package:feathr/services/api.dart'; 7 | import 'package:feathr/screens/about.dart'; 8 | import 'package:feathr/screens/user.dart'; 9 | import 'package:feathr/screens/login.dart'; 10 | import 'package:feathr/screens/timeline_tabs.dart'; 11 | 12 | /// [FeathrApp] is the main, entry widget of the Feathr application. 13 | class FeathrApp extends StatelessWidget { 14 | FeathrApp({super.key}); 15 | 16 | /// Main instance of the API service to be used across the app. 17 | final apiService = ApiService(); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return MaterialApp( 22 | title: 'feathr', 23 | theme: lightThemeData, 24 | darkTheme: darkThemeData, 25 | themeMode: ThemeMode.dark, 26 | initialRoute: '/login', 27 | onGenerateRoute: (settings) { 28 | if (settings.name == '/user') { 29 | final args = settings.arguments as UserScreenArguments; 30 | return MaterialPageRoute( 31 | builder: (context) { 32 | return User(account: args.account, apiService: apiService); 33 | }, 34 | ); 35 | } 36 | 37 | return null; 38 | }, 39 | routes: { 40 | '/login': (context) => Login(apiService: apiService), 41 | '/tabs': (context) => TimelineTabs(apiService: apiService), 42 | '/about': (context) => const About(), 43 | }, 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/data/account.dart: -------------------------------------------------------------------------------- 1 | /// The [Account] class represents the information for a 2 | /// given account. 3 | /// 4 | /// reference: https://docs.joinmastodon.org/entities/account/ 5 | class Account { 6 | /// ID of the account in the Mastodon instance. 7 | final String id; 8 | 9 | /// Username associated to the account. 10 | final String username; 11 | 12 | /// Display name associated to the account. 13 | final String displayName; 14 | 15 | /// Webfinger account URI: username for local users, or username@domain for 16 | /// remote users. 17 | final String acct; 18 | 19 | /// Whether or not the account is locked. 20 | final bool isLocked; 21 | 22 | /// Whether or not the account is a bot 23 | final bool isBot; 24 | 25 | /// URL to the user's set avatar 26 | final String? avatarUrl; 27 | 28 | /// URL to the user's set header 29 | final String? headerUrl; 30 | 31 | Account({ 32 | required this.id, 33 | required this.username, 34 | required this.displayName, 35 | required this.acct, 36 | required this.isLocked, 37 | required this.isBot, 38 | this.avatarUrl, 39 | this.headerUrl, 40 | }); 41 | 42 | /// Given a Json-like [Map] with information for an account, 43 | /// build and return the respective [Account] instance. 44 | factory Account.fromJson(Map data) { 45 | return Account( 46 | id: data["id"]!, 47 | username: data["username"]!, 48 | displayName: data["display_name"]!, 49 | acct: data["acct"]!, 50 | isLocked: data["locked"]!, 51 | isBot: data["bot"]!, 52 | avatarUrl: data["avatar"], 53 | headerUrl: data["header"], 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/data/custom_emoji.dart: -------------------------------------------------------------------------------- 1 | /// The [CustomEmoji] class represents a custom emoji that can be used in 2 | /// Mastodon statuses. 3 | class CustomEmoji { 4 | /// The shortcode of the custom emoji. 5 | final String shortcode; 6 | 7 | /// The URL of the custom emoji. 8 | final String url; 9 | 10 | /// The static URL of the custom emoji. 11 | final String staticUrl; 12 | 13 | CustomEmoji({ 14 | required this.shortcode, 15 | required this.url, 16 | required this.staticUrl, 17 | }); 18 | 19 | /// Creates a [CustomEmoji] instance from a JSON object. 20 | factory CustomEmoji.fromJson(Map json) { 21 | return CustomEmoji( 22 | shortcode: json['shortcode'] as String, 23 | url: json['url'] as String, 24 | staticUrl: json['static_url'] as String, 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/data/status.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:feathr/data/account.dart'; 4 | import 'package:feathr/data/custom_emoji.dart'; 5 | import 'package:relative_time/relative_time.dart'; 6 | 7 | /// The [StatusVisibility] enum represents the visibility of a status 8 | /// in a Mastodon instance. 9 | enum StatusVisibility { public, unlisted, private } 10 | 11 | /// The [Status] class represents information for a given status (toot) 12 | /// made in a Mastodon instance. 13 | /// 14 | /// reference: https://docs.joinmastodon.org/entities/status/ 15 | /// TODO: fill in all necessary fields 16 | class Status { 17 | /// ID of the status in the Mastodon instance it belongs to 18 | final String id; 19 | 20 | /// Datetime when the status was created 21 | final DateTime createdAt; 22 | 23 | /// Main content of the status, in HTML format 24 | final String content; 25 | 26 | /// [Account] instance of the user that created this status 27 | final Account account; 28 | 29 | /// Whether or not the user has favorited this status 30 | final bool favorited; 31 | 32 | /// Whether or not the user has reblogged (boosted, retooted) this status 33 | final bool reblogged; 34 | 35 | /// Whether or not the user has bookmarked this status 36 | final bool bookmarked; 37 | 38 | // If this status is a reblog, the reblogged status content will be available here 39 | final Status? reblog; 40 | 41 | // Amount of times ths status has been favorited 42 | final int favouritesCount; 43 | 44 | // Amount of times this status has been reblogged 45 | final int reblogsCount; 46 | 47 | // Amount of replies to this status 48 | final int repliesCount; 49 | 50 | // Status visibility 51 | final StatusVisibility visibility; 52 | 53 | // Spoiler text (content warning) 54 | final String spoilerText; 55 | 56 | // Custom emojis 57 | final List customEmojis; 58 | 59 | Status({ 60 | required this.id, 61 | required this.createdAt, 62 | required this.content, 63 | required this.account, 64 | required this.favorited, 65 | required this.reblogged, 66 | required this.bookmarked, 67 | required this.favouritesCount, 68 | required this.reblogsCount, 69 | required this.repliesCount, 70 | required this.visibility, 71 | required this.spoilerText, 72 | required this.customEmojis, 73 | this.reblog, 74 | }); 75 | 76 | /// Given a Json-like [Map] with information for a status, 77 | /// build and return the respective [Status] instance. 78 | factory Status.fromJson(Map data) { 79 | return Status( 80 | id: data["id"]!, 81 | createdAt: DateTime.parse(data["created_at"]!), 82 | content: data["content"]!, 83 | account: Account.fromJson(data["account"]!), 84 | favorited: data["favourited"]!, 85 | reblogged: data["reblogged"]!, 86 | bookmarked: data["bookmarked"]!, 87 | favouritesCount: data["favourites_count"]!, 88 | reblogsCount: data["reblogs_count"]!, 89 | repliesCount: data["replies_count"]!, 90 | visibility: StatusVisibility.values.byName(data["visibility"]!), 91 | spoilerText: data["spoiler_text"]!, 92 | reblog: data["reblog"] == null ? null : Status.fromJson(data["reblog"]), 93 | customEmojis: 94 | ((data["emojis"] ?? []) as List) 95 | .map( 96 | (emoji) => CustomEmoji.fromJson(emoji as Map), 97 | ) 98 | .toList(), 99 | ); 100 | } 101 | 102 | /// Returns the processed and augmented content of the [Status] instance, 103 | /// including a note about the reblogged status if applicable, 104 | /// and replacing custom emojis with their respective HTML tags. 105 | String getContent() { 106 | // If this status is a reblog, show the original user's account name 107 | if (reblog != null) { 108 | // TODO: display original user's avatar on reblogs 109 | return "Reblogged from ${reblog!.account.acct}: ${reblog!.getContent()}"; 110 | } 111 | 112 | String processedContent = content; 113 | 114 | // Replacing custom emojis with their respective HTML tags 115 | for (var emoji in customEmojis) { 116 | processedContent = processedContent.replaceAll( 117 | ":${emoji.shortcode}:", 118 | '${emoji.shortcode}', 119 | ); 120 | } 121 | 122 | return processedContent; 123 | } 124 | 125 | String getRelativeDate() { 126 | // TODO: set up localization for the app 127 | return createdAt.relativeTimeLocale(const Locale('en')); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /lib/helpers/auth.dart: -------------------------------------------------------------------------------- 1 | import 'package:oauth2_client/oauth2_client.dart'; 2 | import 'package:oauth2_client/oauth2_helper.dart'; 3 | 4 | /// Custom URI scheme used for redirection after auth 5 | const String featherUriScheme = 'space.feathr.app'; 6 | 7 | /// URI for redirection after successful auth 8 | const String featherRedirectUri = 'space.feathr.app://oauth-callback'; 9 | 10 | /// List of oauth scopes to be requested to Mastodon on authentication 11 | const List oauthScopes = ['read', 'write', 'follow']; 12 | 13 | /// The [FeathrOAuth2Client] is a custom [OAuth2Client] class for use with 14 | /// a Mastodon instance's OAuth2 endpoints. 15 | class FeathrOAuth2Client extends OAuth2Client { 16 | /// URL of the Mastodon instance to perform auth with 17 | final String instanceUrl; 18 | 19 | FeathrOAuth2Client({required this.instanceUrl}) 20 | : super( 21 | authorizeUrl: '$instanceUrl/oauth/authorize', 22 | tokenUrl: '$instanceUrl/oauth/token', 23 | redirectUri: featherRedirectUri, 24 | customUriScheme: featherUriScheme, 25 | ); 26 | } 27 | 28 | /// Returns an instance of the [OAuth2Helper] helper class that serves as a 29 | /// bridge between the OAuth2 auth flow and requests to Mastodon's endpoint. 30 | OAuth2Helper getOauthHelper( 31 | String instanceUrl, 32 | String oauthClientId, 33 | String oauthClientSecret, 34 | ) { 35 | final FeathrOAuth2Client oauthClient = FeathrOAuth2Client( 36 | instanceUrl: instanceUrl, 37 | ); 38 | 39 | return OAuth2Helper( 40 | oauthClient, 41 | grantType: OAuth2Helper.authorizationCode, 42 | clientId: oauthClientId, 43 | clientSecret: oauthClientSecret, 44 | scopes: oauthScopes, 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'package:feathr/app.dart'; 4 | 5 | void main() async { 6 | // Launching the app! 7 | runApp(FeathrApp()); 8 | } 9 | -------------------------------------------------------------------------------- /lib/screens/about.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'package:package_info_plus/package_info_plus.dart'; 4 | 5 | import 'package:feathr/widgets/title.dart'; 6 | 7 | /// The [About] screen displays information about the `Feather` project, its 8 | /// license, contributors, URLs, version and credits, among other things. 9 | class About extends StatefulWidget { 10 | const About({super.key}); 11 | 12 | @override 13 | AboutState createState() => AboutState(); 14 | } 15 | 16 | /// [AboutState] wraps the logic and state for the [About] screen. 17 | class AboutState extends State { 18 | /// Version of the current build of the app, obtained asynchronously. 19 | String? version; 20 | 21 | /// Build number of the current build of the app, obtained asynchronously. 22 | String? buildNumber; 23 | 24 | @override 25 | void initState() { 26 | super.initState(); 27 | fetchVersionNumber(); 28 | } 29 | 30 | /// Obtains and stores the current version number in the widget's state. 31 | Future fetchVersionNumber() async { 32 | PackageInfo packageInfo = await PackageInfo.fromPlatform(); 33 | setState(() { 34 | version = packageInfo.version; 35 | buildNumber = packageInfo.buildNumber; 36 | }); 37 | } 38 | 39 | /// Returns a version tag as a `String`. 40 | String getVersionTag() { 41 | if (version != null) { 42 | return "Version $version (build: $buildNumber)"; 43 | } 44 | 45 | return ""; 46 | } 47 | 48 | @override 49 | Widget build(BuildContext context) { 50 | return Scaffold( 51 | body: Container( 52 | padding: const EdgeInsets.all(50), 53 | child: Column( 54 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 55 | children: [ 56 | Column( 57 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 58 | children: [ 59 | const Center(child: TitleWidget("feathr")), 60 | Text(getVersionTag()), 61 | const Divider(), 62 | const Text( 63 | "feathr is a free, open source project created by Andrés Ignacio Torres (github: @aitorres).", 64 | ), 65 | const Divider(), 66 | const Text( 67 | "feathr is licensed under the GNU Affero General Public License.", 68 | ), 69 | ], 70 | ), 71 | ], 72 | ), 73 | ), 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/screens/login.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:oauth2_client/access_token_response.dart'; 3 | import 'package:package_info_plus/package_info_plus.dart'; 4 | 5 | import 'package:feathr/services/api.dart'; 6 | import 'package:feathr/utils/messages.dart'; 7 | import 'package:feathr/widgets/instance_form.dart'; 8 | import 'package:feathr/widgets/title.dart'; 9 | import 'package:feathr/widgets/buttons.dart'; 10 | 11 | /// The [Login] screen renders an initial view of the app for unauthenticated 12 | /// users, allowing them to log into the application with their Mastodon 13 | /// credentials. 14 | /// TODO: add tests for this widget 15 | class Login extends StatefulWidget { 16 | /// Main instance of the API service to use in the widget. 17 | final ApiService apiService; 18 | 19 | const Login({super.key, required this.apiService}); 20 | 21 | @override 22 | State createState() => _LoginState(); 23 | } 24 | 25 | /// The [_LoginState] wraps the logic and state for the [Login] screen. 26 | class _LoginState extends State { 27 | /// Version of the current build of the app, obtained asynchronously. 28 | String? version; 29 | 30 | /// Determines whether or not to show the login button 31 | bool showLoginButton = false; 32 | 33 | @override 34 | void initState() { 35 | super.initState(); 36 | fetchVersionNumber(); 37 | checkAuthStatus(); 38 | } 39 | 40 | /// Obtains and stores the current version number in the widget's state. 41 | Future fetchVersionNumber() async { 42 | PackageInfo packageInfo = await PackageInfo.fromPlatform(); 43 | setState(() { 44 | version = packageInfo.version; 45 | }); 46 | } 47 | 48 | /// Determines whether the user is logged-in or not, and sets up the widget 49 | /// for the next action in either case. 50 | Future checkAuthStatus() async { 51 | // Attempts to restore the API service status from the device's 52 | // secure storage 53 | await widget.apiService.loadApiServiceFromStorage(); 54 | 55 | // If the previous call successfully restored the API status, then the 56 | // `helper` was appropriately instanced. 57 | if (widget.apiService.helper != null) { 58 | AccessTokenResponse? token = 59 | await widget.apiService.helper!.getTokenFromStorage(); 60 | 61 | // This would check if, besides having a working `helper`, we also have 62 | // a user token stored. 63 | if (token != null) { 64 | // At this point we have a valid API service instance. This call will 65 | // attempt to load data, and if it fails, it'd reset the API service 66 | // status and come back to the log-in screen. 67 | return onValidAuth(); 68 | } 69 | } 70 | 71 | // At this point, we can safely assume we need the user to log in 72 | return setState(() { 73 | showLoginButton = true; 74 | }); 75 | } 76 | 77 | /// Displays a form that asks the user for a Mastodon instance URL and 78 | /// triggers the log in process on successful submit. 79 | void selectInstanceAction() { 80 | showDialog( 81 | context: context, 82 | builder: 83 | (context) => AlertDialog( 84 | title: const Text("Enter your Mastodon instance"), 85 | content: InstanceForm(onSuccessfulSubmit: logInAction), 86 | ), 87 | ); 88 | } 89 | 90 | /// Helper function to report an error during the log in process, making 91 | /// sure that we also clean the API state (to err on the side of caution) 92 | void reportLogInError(String message) { 93 | setState(() { 94 | showLoginButton = true; 95 | }); 96 | widget.apiService.resetApiServiceState(); 97 | showSnackBar(context, message); 98 | } 99 | 100 | /// Attempts to register an app on a Mastodon instance (given by its URL), 101 | /// and then attempts to request the user to log in, using the API service. 102 | Future logInAction(String instanceUrl) async { 103 | // This triggers the loading spinner. `reportLogInError` would revert this, 104 | // if it gets called. 105 | setState(() { 106 | showLoginButton = false; 107 | }); 108 | 109 | // Attempting to register `feathr` as an app on the user-specified instance 110 | try { 111 | await widget.apiService.registerApp(instanceUrl); 112 | } on ApiException { 113 | return reportLogInError( 114 | "We couldn't connect to $instanceUrl as a Mastodon instance. Please check the URL and try again!", 115 | ); 116 | } 117 | 118 | // We could register the app succesfully. Attempting to log in the user. 119 | try { 120 | return onValidAuth(); 121 | } on ApiException { 122 | return reportLogInError( 123 | "We couldn't log you in with your specified credentials. Please try again!", 124 | ); 125 | } 126 | } 127 | 128 | /// Logs in a user and routes them to the tabbed timeline view. 129 | void onValidAuth() async { 130 | final account = await widget.apiService.logIn(); 131 | 132 | if (mounted) { 133 | showSnackBar( 134 | context, 135 | "Successfully logged in. Welcome, ${account.username}!", 136 | ); 137 | Navigator.pushNamedAndRemoveUntil(context, '/tabs', (route) => false); 138 | } 139 | } 140 | 141 | /// Returns a version tag as a `String`. 142 | String getVersionTag() { 143 | if (version != null) { 144 | return "Version $version"; 145 | } 146 | 147 | return ""; 148 | } 149 | 150 | /// Returns either a loading indicator or a login button, depending 151 | /// on a boolean state variable, intended to show the right widget 152 | /// while the app checks if the user is logged in. 153 | Widget getActionWidget() { 154 | if (!showLoginButton) { 155 | return const CircularProgressIndicator(); 156 | } 157 | 158 | return FeathrActionButton( 159 | onPressed: selectInstanceAction, 160 | buttonText: "Get started", 161 | ); 162 | } 163 | 164 | @override 165 | Widget build(BuildContext context) { 166 | return Scaffold( 167 | body: Container( 168 | padding: const EdgeInsets.all(50), 169 | child: Column( 170 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 171 | children: [ 172 | Column( 173 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 174 | children: [ 175 | const Center(child: TitleWidget("feathr")), 176 | Text(getVersionTag()), 177 | ], 178 | ), 179 | getActionWidget(), 180 | ], 181 | ), 182 | ), 183 | ); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /lib/screens/timeline_tabs.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'package:flutter_feather_icons/flutter_feather_icons.dart'; 4 | 5 | import 'package:feathr/data/account.dart'; 6 | import 'package:feathr/services/api.dart'; 7 | import 'package:feathr/utils/messages.dart'; 8 | import 'package:feathr/widgets/drawer.dart'; 9 | import 'package:feathr/widgets/timeline.dart'; 10 | 11 | import 'package:feathr/widgets/status_form.dart'; 12 | 13 | /// The [TimelineTabs] widget represents the tab wrapper for the main 14 | /// view of the Feathr app. 15 | class TimelineTabs extends StatefulWidget { 16 | /// Title to use in the Scaffold 17 | static const String title = 'feathr'; 18 | 19 | /// Main instance of the API service to use in the widget. 20 | final ApiService apiService; 21 | 22 | const TimelineTabs({super.key, required this.apiService}); 23 | 24 | @override 25 | State createState() => _TimelineTabsState(); 26 | } 27 | 28 | /// The [_TimelineTabsState] class wraps up logic and state for the [TimelineTabs] screen. 29 | class _TimelineTabsState extends State { 30 | Account? account; 31 | 32 | @override 33 | void initState() { 34 | super.initState(); 35 | fetchAccount(); 36 | } 37 | 38 | /// Fetches the account stored in the global application state through 39 | /// the API service, and updates the state of the widget. 40 | void fetchAccount() async { 41 | Account currentAccount = await widget.apiService.getCurrentAccount(); 42 | setState(() { 43 | account = currentAccount; 44 | }); 45 | } 46 | 47 | /// Renders the header of the application drawer with user data taken 48 | /// from the application global state, or a spinner. 49 | Widget getDrawerHeader() { 50 | if (account != null) { 51 | return FeathrDrawerHeader(account: account!); 52 | } 53 | 54 | return const CircularProgressIndicator(); 55 | } 56 | 57 | /// Renders an application drawer, to be used as a complement for navigation 58 | /// in the app's main tabbed view. 59 | Widget getDrawer(BuildContext context) { 60 | return Drawer( 61 | child: ListView( 62 | padding: EdgeInsets.zero, 63 | children: [ 64 | getDrawerHeader(), 65 | ListTile( 66 | title: const Text('About feathr'), 67 | onTap: () async { 68 | Navigator.of(context).pop(); 69 | Navigator.of(context).pushNamed('/about'); 70 | }, 71 | ), 72 | ListTile( 73 | title: const Text('Log out'), 74 | onTap: () async { 75 | await widget.apiService.logOut(); 76 | 77 | if (context.mounted) { 78 | showSnackBar(context, "Logged out successfully. Goodbye!"); 79 | Navigator.pushNamedAndRemoveUntil( 80 | context, 81 | '/login', 82 | (route) => false, 83 | ); 84 | } 85 | }, 86 | ), 87 | ], 88 | ), 89 | ); 90 | } 91 | 92 | /// Displays a dialog box with a form to post a status. 93 | void postStatusAction() { 94 | StatusForm.displayStatusFormWindow( 95 | context, 96 | widget.apiService, 97 | replyToStatus: null, 98 | ); 99 | } 100 | 101 | @override 102 | Widget build(BuildContext context) { 103 | final List tabs = [ 104 | Timeline( 105 | apiService: widget.apiService, 106 | timelineType: TimelineType.home, 107 | tabIcon: const Tab( 108 | icon: Icon(FeatherIcons.home, size: 14), 109 | text: "Home", 110 | ), 111 | ), 112 | Timeline( 113 | apiService: widget.apiService, 114 | timelineType: TimelineType.local, 115 | tabIcon: const Tab( 116 | icon: Icon(FeatherIcons.monitor, size: 16), 117 | text: "Local", 118 | ), 119 | ), 120 | Timeline( 121 | apiService: widget.apiService, 122 | timelineType: TimelineType.fedi, 123 | tabIcon: const Tab( 124 | icon: Icon(FeatherIcons.globe, size: 16), 125 | text: "Fedi", 126 | ), 127 | ), 128 | ]; 129 | 130 | return DefaultTabController( 131 | length: 3, 132 | child: Scaffold( 133 | appBar: AppBar( 134 | toolbarHeight: 48.0, 135 | title: const Text(TimelineTabs.title), 136 | bottom: TabBar(tabs: tabs.map((tab) => tab.tabIcon).toList()), 137 | ), 138 | drawer: getDrawer(context), 139 | body: Stack( 140 | children: [ 141 | TabBarView(children: tabs), 142 | Positioned( 143 | bottom: 32.0, 144 | right: 32.0, 145 | child: FloatingActionButton( 146 | onPressed: postStatusAction, 147 | child: const Icon(Icons.create_rounded), 148 | ), 149 | ), 150 | ], 151 | ), 152 | ), 153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /lib/screens/user.dart: -------------------------------------------------------------------------------- 1 | import 'package:feathr/services/api.dart'; 2 | import 'package:feathr/widgets/drawer.dart'; 3 | import 'package:feathr/widgets/timeline.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | import 'package:feathr/data/account.dart'; 7 | import 'package:flutter_feather_icons/flutter_feather_icons.dart'; 8 | 9 | class UserScreenArguments { 10 | final Account account; 11 | 12 | UserScreenArguments(this.account); 13 | } 14 | 15 | /// The [User] screen displays information about any user profile, 16 | /// whether it's the currently-logged user or not. 17 | class User extends StatefulWidget { 18 | /// The user's account information. 19 | final Account account; 20 | 21 | /// The [ApiService] instance to use in the widget. 22 | final ApiService apiService; 23 | 24 | const User({super.key, required this.account, required this.apiService}); 25 | 26 | @override 27 | UserState createState() => UserState(); 28 | } 29 | 30 | /// [UserState] wraps the logic and state for the [User] screen. 31 | class UserState extends State { 32 | @override 33 | void initState() { 34 | super.initState(); 35 | } 36 | 37 | @override 38 | Widget build(BuildContext context) { 39 | /// TODO: Add more details about the user profile (e.g. bio, etc). 40 | return Scaffold( 41 | body: Column( 42 | mainAxisAlignment: MainAxisAlignment.start, 43 | children: [ 44 | FeathrDrawerHeader(account: widget.account), 45 | Expanded( 46 | child: Timeline( 47 | apiService: widget.apiService, 48 | timelineType: TimelineType.user, 49 | tabIcon: const Tab( 50 | icon: Icon(FeatherIcons.user, size: 16), 51 | text: "User", 52 | ), 53 | accountId: widget.account.id, 54 | ), 55 | ), 56 | ], 57 | ), 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/services/api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:http/http.dart' as http; 4 | import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 5 | import 'package:oauth2_client/oauth2_helper.dart'; 6 | 7 | import 'package:feathr/helpers/auth.dart'; 8 | import 'package:feathr/data/status.dart'; 9 | import 'package:feathr/data/account.dart'; 10 | 11 | /// Custom exception to be thrown by the API service for unhandled cases. 12 | class ApiException implements Exception { 13 | /// Message or cause of the API exception. 14 | final String message; 15 | 16 | ApiException(this.message); 17 | } 18 | 19 | /// Each [TimelineType] represents a specific set of restrictions for querying 20 | /// a Mastodon timeline. 21 | enum TimelineType { home, local, fedi, user } 22 | 23 | /// [ApiService] holds a collection of useful auxiliary functions to 24 | /// interact with Mastodon's API. 25 | class ApiService { 26 | /// Access to the device's secure storage to persist session values 27 | static const FlutterSecureStorage secureStorage = FlutterSecureStorage(); 28 | 29 | /// URL of the Mastodon instance to perform auth with 30 | String? instanceUrl; 31 | 32 | /// Client key for authentication with Mastodon 33 | /// (available after app registration) 34 | String? oauthClientId; 35 | 36 | /// Client secret for authentication with Mastodon 37 | /// (available after app registration) 38 | String? oauthClientSecret; 39 | 40 | /// Helper to make authenticated requests to Mastodon. 41 | OAuth2Helper? helper; 42 | 43 | /// [Account] instance of the current logged-in user. 44 | Account? currentAccount; 45 | 46 | /// [http.Client] instance to perform queries (is overriden for tests) 47 | http.Client httpClient = http.Client(); 48 | 49 | /// Performs a GET request to the specified URL through the API helper 50 | Future _apiGet(String url) async { 51 | return await helper!.get(url, httpClient: httpClient); 52 | } 53 | 54 | /// Performs a POST request to the specified URL through the API helper 55 | Future _apiPost(String url) async { 56 | return await helper!.post(url, httpClient: httpClient); 57 | } 58 | 59 | /// Registers a new `app` on a Mastodon instance and sets the client tokens on 60 | /// the current state of the API service instance 61 | Future getClientCredentials() async { 62 | final apiUrl = "${instanceUrl!}/api/v1/apps"; 63 | 64 | // Attempting to register the app 65 | http.Response resp; 66 | try { 67 | resp = await httpClient.post( 68 | Uri.parse(apiUrl), 69 | body: { 70 | "client_name": "feathr", 71 | "redirect_uris": featherRedirectUri, 72 | "scopes": oauthScopes.join(" "), 73 | "website": "https://feathr.space", 74 | }, 75 | ); 76 | } on Exception { 77 | // This probably means that the `instanceUrl` does not actually point 78 | // towards a valid Mastodon instance 79 | throw ApiException( 80 | "Error connecting to server on `getClientCredentials`", 81 | ); 82 | } 83 | 84 | if (resp.statusCode == 200) { 85 | // Setting the client tokens 86 | Map jsonData = jsonDecode(resp.body); 87 | oauthClientId = jsonData["client_id"]; 88 | oauthClientSecret = jsonData["client_secret"]; 89 | return; 90 | } 91 | 92 | throw ApiException( 93 | "Unexpected status code ${resp.statusCode} on `getClientCredentials`", 94 | ); 95 | } 96 | 97 | /// Creates a new instance of the Oauth Helper with the current state of 98 | /// the API service instance (if valid), or deletes the current one if 99 | /// there are null values in the state. 100 | void setHelper() { 101 | if (instanceUrl != null && 102 | oauthClientId != null && 103 | oauthClientSecret != null) { 104 | helper = getOauthHelper(instanceUrl!, oauthClientId!, oauthClientSecret!); 105 | } else { 106 | helper = null; 107 | } 108 | } 109 | 110 | /// Given a Mastodon instance URL, attempts to create a new `app` in the 111 | /// server for `feathr`, updating the device's secure storage in order 112 | /// to preserve the app tokens and instance URL. 113 | Future registerApp(String newInstanceUrl) async { 114 | // Adding the protocol / scheme if needed 115 | if (!newInstanceUrl.contains("://")) { 116 | instanceUrl = "https://$newInstanceUrl"; 117 | } else { 118 | instanceUrl = newInstanceUrl; 119 | } 120 | 121 | // This call would set `instanceUrl`, `oauthClientId` and 122 | // `oauthClientSecret` if everything works as expected 123 | await getClientCredentials(); 124 | 125 | // This call would set `helper` 126 | setHelper(); 127 | 128 | // Persisting information in secure storage 129 | await secureStorage.write(key: "instanceUrl", value: instanceUrl); 130 | await secureStorage.write(key: "oauthClientId", value: oauthClientId); 131 | await secureStorage.write( 132 | key: "oauthClientSecret", 133 | value: oauthClientSecret, 134 | ); 135 | } 136 | 137 | /// Attempts to set the API service instance's status from the device's 138 | /// secure storage. Succeeds if `registerApp` ran before and the 139 | /// storage has not been deleted. Useful to restore the API service 140 | /// credentials when restarting the app for a logged in user. 141 | Future loadApiServiceFromStorage() async { 142 | instanceUrl = await secureStorage.read(key: "instanceUrl"); 143 | oauthClientId = await secureStorage.read(key: "oauthClientId"); 144 | oauthClientSecret = await secureStorage.read(key: "oauthClientSecret"); 145 | 146 | setHelper(); 147 | } 148 | 149 | /// Given a timeline type, an optional maxId and a limit of statuses, 150 | /// requests the `limit` amount of statuses from the selected timeline 151 | /// (according to the `timelineType`), using `maxId` as an optional 152 | /// starting point. 153 | Future> getStatusList( 154 | TimelineType timelineType, 155 | String? maxId, 156 | int limit, { 157 | String? accountId, 158 | }) async { 159 | // Depending on the type, we select and restrict the api URL to use, 160 | // limiting the amount of posts we're requesting according to `limit` 161 | String apiUrl; 162 | 163 | if (timelineType == TimelineType.home) { 164 | apiUrl = "${instanceUrl!}/api/v1/timelines/home?limit=$limit"; 165 | } else if (timelineType == TimelineType.user) { 166 | if (accountId == null) { 167 | throw ApiException( 168 | "You must provide an `accountId` for the `user` timeline type", 169 | ); 170 | } 171 | 172 | apiUrl = 173 | "${instanceUrl!}/api/v1/accounts/$accountId/statuses?limit=$limit"; 174 | } else { 175 | // Both the Local and the Fedi timelines use the same base endpoint 176 | apiUrl = "${instanceUrl!}/api/v1/timelines/public?limit=$limit"; 177 | 178 | if (timelineType == TimelineType.local) { 179 | apiUrl += "?local=true"; 180 | } 181 | } 182 | 183 | // If `maxId` is not null, we'll use it as a threshold so that we only 184 | // get posts older (further down in the timeline) than this one 185 | if (maxId != null) { 186 | apiUrl += "?max_id=$maxId"; 187 | } 188 | 189 | http.Response resp = await _apiGet(apiUrl); 190 | if (resp.statusCode == 200) { 191 | // The response is a list of json objects 192 | List jsonDataList = jsonDecode(resp.body); 193 | 194 | return jsonDataList 195 | .map( 196 | (statusData) => Status.fromJson(statusData as Map), 197 | ) 198 | .toList(); 199 | } 200 | 201 | throw ApiException( 202 | "Unexpected status code ${resp.statusCode} on `getStatusList`", 203 | ); 204 | } 205 | 206 | /// Returns the current account as cached in the instance, 207 | /// retrieving the account details from the API first if needed. 208 | Future getCurrentAccount() async { 209 | if (currentAccount != null) { 210 | return currentAccount!; 211 | } 212 | 213 | return await getAccount(); 214 | } 215 | 216 | /// Retrieve and return the [Account] instance associated to the current 217 | /// credentials by querying the API. Updates the `this.currentAccount` 218 | /// instance attribute in the process. 219 | Future getAccount() async { 220 | final apiUrl = "${instanceUrl!}/api/v1/accounts/verify_credentials"; 221 | http.Response resp = await _apiGet(apiUrl); 222 | 223 | if (resp.statusCode == 200) { 224 | Map jsonData = jsonDecode(resp.body); 225 | currentAccount = Account.fromJson(jsonData); 226 | return currentAccount!; 227 | } 228 | 229 | throw ApiException( 230 | "Unexpected status code ${resp.statusCode} on `getAccount`", 231 | ); 232 | } 233 | 234 | /// Given a [Status]'s ID, requests the Mastodon API to favorite 235 | /// the status. Note that this is idempotent: an already-favorited 236 | /// status will remain favorited. Returns the (new) [Status] instance 237 | /// the API responds with. 238 | Future favoriteStatus(String statusId) async { 239 | final apiUrl = "${instanceUrl!}/api/v1/statuses/$statusId/favourite"; 240 | http.Response resp = await _apiPost(apiUrl); 241 | 242 | if (resp.statusCode == 200) { 243 | Map jsonData = jsonDecode(resp.body); 244 | return Status.fromJson(jsonData); 245 | } 246 | 247 | throw ApiException( 248 | "Unexpected status code ${resp.statusCode} on `favoriteStatus`", 249 | ); 250 | } 251 | 252 | /// Given a [Status]'s ID, requests the Mastodon API to un-favorite 253 | /// the status. Note that this is idempotent: a non-favorited 254 | /// status will remain non-favorited. Returns the (new) [Status] instance 255 | /// the API responds with. 256 | Future undoFavoriteStatus(String statusId) async { 257 | final apiUrl = "${instanceUrl!}/api/v1/statuses/$statusId/unfavourite"; 258 | http.Response resp = await _apiPost(apiUrl); 259 | 260 | if (resp.statusCode == 200) { 261 | Map jsonData = jsonDecode(resp.body); 262 | return Status.fromJson(jsonData); 263 | } 264 | 265 | throw ApiException( 266 | "Unexpected status code ${resp.statusCode} on `undoFavoriteStatus`", 267 | ); 268 | } 269 | 270 | /// Given a status content, requests the Mastodon API to post a new status 271 | /// on the user's timeline. Returns the (new) [Status] instance the API 272 | /// responds with. 273 | Future postStatus( 274 | String content, { 275 | Status? replyToStatus, 276 | StatusVisibility visibility = StatusVisibility.public, 277 | String spoilerText = "", 278 | }) async { 279 | final apiUrl = "${instanceUrl!}/api/v1/statuses"; 280 | // TODO: Support sensitivity, language, scheduling, polls and media 281 | Map body = { 282 | "status": content, 283 | "visibility": visibility.name, 284 | "spoiler_text": spoilerText, 285 | }; 286 | if (replyToStatus != null) { 287 | body["in_reply_to_id"] = replyToStatus.id; 288 | } 289 | 290 | http.Response resp = await helper!.post( 291 | apiUrl, 292 | body: body, 293 | httpClient: httpClient, 294 | ); 295 | 296 | if (resp.statusCode == 200) { 297 | Map jsonData = jsonDecode(resp.body); 298 | return Status.fromJson(jsonData); 299 | } 300 | 301 | throw ApiException( 302 | "Unexpected status code ${resp.statusCode} on `postStatus`", 303 | ); 304 | } 305 | 306 | /// Given a [Status]'s ID, requests the Mastodon API to bookmark 307 | /// the status. Note that this is idempotent: an already-bookmarked 308 | /// status will remain bookmarked. Returns the (new) [Status] instance 309 | /// the API responds with. 310 | Future bookmarkStatus(String statusId) async { 311 | final apiUrl = "${instanceUrl!}/api/v1/statuses/$statusId/bookmark"; 312 | http.Response resp = await _apiPost(apiUrl); 313 | 314 | if (resp.statusCode == 200) { 315 | Map jsonData = jsonDecode(resp.body); 316 | return Status.fromJson(jsonData); 317 | } 318 | 319 | throw ApiException( 320 | "Unexpected status code ${resp.statusCode} on `bookmarkStatus`", 321 | ); 322 | } 323 | 324 | /// Given a [Status]'s ID, requests the Mastodon API to un-bookmark 325 | /// the status. Note that this is idempotent: a non-bookmarked 326 | /// status will remain non-bookmarked. Returns the (new) [Status] instance 327 | /// the API responds with. 328 | Future undoBookmarkStatus(String statusId) async { 329 | final apiUrl = "${instanceUrl!}/api/v1/statuses/$statusId/unbookmark"; 330 | http.Response resp = await _apiPost(apiUrl); 331 | 332 | if (resp.statusCode == 200) { 333 | Map jsonData = jsonDecode(resp.body); 334 | return Status.fromJson(jsonData); 335 | } 336 | 337 | throw ApiException( 338 | "Unexpected status code ${resp.statusCode} on `undoBookmarkStatus`", 339 | ); 340 | } 341 | 342 | /// Given a [Status]'s ID, requests the Mastodon API to boost 343 | /// the status. Note that this is idempotent: an already-boosted 344 | /// status will remain boosted. Returns the (new) [Status] instance 345 | /// the API responds with. 346 | Future boostStatus(String statusId) async { 347 | final apiUrl = "${instanceUrl!}/api/v1/statuses/$statusId/reblog"; 348 | http.Response resp = await _apiPost(apiUrl); 349 | 350 | if (resp.statusCode == 200) { 351 | Map jsonData = jsonDecode(resp.body); 352 | return Status.fromJson(jsonData["reblog"]); 353 | } 354 | 355 | throw ApiException( 356 | "Unexpected status code ${resp.statusCode} on `boostStatus`", 357 | ); 358 | } 359 | 360 | /// Given a [Status]'s ID, requests the Mastodon API to un-boost 361 | /// the status. Note that this is idempotent: a non-boosted 362 | /// status will remain non-boosted. Returns the (new) [Status] instance 363 | /// the API responds with. 364 | Future undoBoostStatus(String statusId) async { 365 | final apiUrl = "${instanceUrl!}/api/v1/statuses/$statusId/unreblog"; 366 | http.Response resp = await _apiPost(apiUrl); 367 | 368 | if (resp.statusCode == 200) { 369 | Map jsonData = jsonDecode(resp.body); 370 | return Status.fromJson(jsonData["reblog"]); 371 | } 372 | 373 | throw ApiException( 374 | "Unexpected status code ${resp.statusCode} on `undoBoostStatus`", 375 | ); 376 | } 377 | 378 | /// Performs an authenticated query to the API in order to force the log-in 379 | /// view. In the process, sets the `this.currentAccount` instance attribute. 380 | Future logIn() async { 381 | // If the user is not authenticated, `helper` will automatically 382 | // request for authentication while calling this method 383 | return await getAccount(); 384 | } 385 | 386 | /// Invalidates the stored client tokens server-side and then deletes 387 | /// all tokens from the secure storage, effectively logging the user out. 388 | Future logOut() async { 389 | // Revoking credentials on server's side 390 | final apiUrl = "${instanceUrl!}/oauth/revoke"; 391 | await _apiPost(apiUrl); 392 | 393 | // Revoking credentials locally 394 | await helper!.removeAllTokens(); 395 | 396 | // Resetting state of the API service 397 | await resetApiServiceState(); 398 | } 399 | 400 | /// Revokes all API service credentials & state variables from the 401 | /// device's secure storage, and sets their values as `null` in the 402 | /// instance. 403 | Future resetApiServiceState() async { 404 | await secureStorage.delete(key: "oauthClientId"); 405 | await secureStorage.delete(key: "oauthClientSecret"); 406 | await secureStorage.delete(key: "instanceUrl"); 407 | 408 | oauthClientId = null; 409 | oauthClientSecret = null; 410 | instanceUrl = null; 411 | helper = null; 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /lib/themes/dark.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | ThemeData darkThemeData = ThemeData.dark(); 4 | -------------------------------------------------------------------------------- /lib/themes/light.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | ThemeData lightThemeData = ThemeData.light(); 4 | -------------------------------------------------------------------------------- /lib/utils/messages.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | void showSnackBar(BuildContext context, String message) { 4 | final snackBar = SnackBar(content: Text(message)); 5 | ScaffoldMessenger.of(context).showSnackBar(snackBar); 6 | } 7 | -------------------------------------------------------------------------------- /lib/widgets/buttons.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// The [FeathrActionButton] widget renders a button, to be used as a 4 | /// primary button whenever an action is expected from the user. 5 | class FeathrActionButton extends StatelessWidget { 6 | final void Function()? onPressed; 7 | final String buttonText; 8 | 9 | const FeathrActionButton({ 10 | required this.onPressed, 11 | required this.buttonText, 12 | super.key, 13 | }); 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return Container( 18 | margin: const EdgeInsets.only(top: 24.0), 19 | child: ElevatedButton( 20 | onPressed: onPressed, 21 | style: ElevatedButton.styleFrom(backgroundColor: Colors.deepPurple), 22 | child: Text( 23 | buttonText, 24 | style: const TextStyle(fontFamily: "Urbanist", fontSize: 18), 25 | ), 26 | ), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/widgets/drawer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'package:feathr/data/account.dart'; 4 | 5 | /// The [FeathrDrawerHeader] widget stores and displays information about 6 | /// the currently logged-in user account in a drawer that will be displayed 7 | /// on the tab view. 8 | class FeathrDrawerHeader extends StatelessWidget { 9 | final Account account; 10 | 11 | const FeathrDrawerHeader({required this.account, super.key}); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return UserAccountsDrawerHeader( 16 | accountName: Container( 17 | padding: EdgeInsets.symmetric(horizontal: 4), 18 | decoration: BoxDecoration( 19 | color: Colors.black.withValues(alpha: 0.65), 20 | borderRadius: BorderRadius.circular(8), 21 | ), 22 | child: Text(account.displayName, style: TextStyle(color: Colors.white)), 23 | ), 24 | accountEmail: Container( 25 | padding: EdgeInsets.symmetric(horizontal: 4), 26 | decoration: BoxDecoration( 27 | color: Colors.black.withValues(alpha: 0.65), 28 | borderRadius: BorderRadius.circular(8), 29 | ), 30 | child: Text(account.acct, style: TextStyle(color: Colors.white)), 31 | ), 32 | currentAccountPicture: CircleAvatar( 33 | foregroundImage: 34 | account.avatarUrl != null ? NetworkImage(account.avatarUrl!) : null, 35 | ), 36 | decoration: BoxDecoration( 37 | image: 38 | account.headerUrl != null 39 | ? DecorationImage( 40 | image: NetworkImage(account.headerUrl!), 41 | fit: BoxFit.cover, 42 | ) 43 | : null, 44 | color: account.headerUrl == null ? Colors.teal : null, 45 | ), 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/widgets/instance_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'package:validators/validators.dart'; 4 | 5 | import 'package:feathr/widgets/buttons.dart'; 6 | 7 | /// [InstanceForm] is a form widget that requests the user for a domain 8 | /// (text input) that would be used as the base URL for API calls 9 | class InstanceForm extends StatefulWidget { 10 | /// A function that takes a string (the instance URL) and performs an action, 11 | /// to be called when the user submits a valid input. 12 | final Function(String) onSuccessfulSubmit; 13 | 14 | const InstanceForm({required this.onSuccessfulSubmit, super.key}); 15 | 16 | @override 17 | InstanceFormState createState() { 18 | return InstanceFormState(); 19 | } 20 | } 21 | 22 | /// The [InstanceFormState] class wraps up logic and state for 23 | /// the [InstanceForm] screen. 24 | class InstanceFormState extends State { 25 | /// Global key that uniquely identifies this form. 26 | final _formKey = GlobalKey(); 27 | 28 | /// Controller for the `instance` text field, to preserve and access the 29 | /// value set on the input field on this class. 30 | final instanceController = TextEditingController(); 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | // Build a Form widget using the _formKey created above. 35 | return Form( 36 | key: _formKey, 37 | autovalidateMode: AutovalidateMode.onUserInteraction, 38 | child: Column( 39 | mainAxisSize: MainAxisSize.min, 40 | children: [ 41 | TextFormField( 42 | keyboardType: TextInputType.url, 43 | decoration: const InputDecoration( 44 | helperText: "Enter a domain, e.g. mastodon.social", 45 | ), 46 | controller: instanceController, 47 | validator: (value) { 48 | if (value == null || value.isEmpty) { 49 | return 'This field should not be empty'; 50 | } 51 | 52 | if (!isURL(value)) { 53 | return "Please enter a valid URL"; 54 | } 55 | 56 | return null; 57 | }, 58 | ), 59 | FeathrActionButton( 60 | onPressed: () { 61 | if (_formKey.currentState!.validate()) { 62 | // Unfocusing the keyboard and the dialog box 63 | Navigator.of(context).pop(); 64 | 65 | // Calling the success function with the final value of the 66 | // `instance` text field 67 | widget.onSuccessfulSubmit(instanceController.text); 68 | } 69 | }, 70 | buttonText: "Log in!", 71 | ), 72 | ], 73 | ), 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/widgets/status_card.dart: -------------------------------------------------------------------------------- 1 | import 'package:feathr/screens/user.dart'; 2 | import 'package:feathr/services/api.dart'; 3 | import 'package:feathr/widgets/status_form.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | import 'package:flutter_feather_icons/flutter_feather_icons.dart'; 7 | import 'package:flutter_html/flutter_html.dart'; 8 | import 'package:url_launcher/url_launcher.dart'; 9 | 10 | import 'package:feathr/data/status.dart'; 11 | import 'package:feathr/utils/messages.dart'; 12 | 13 | /// The [StatusCard] widget wraps and displays information for a given 14 | /// [Status] instance. 15 | class StatusCard extends StatefulWidget { 16 | /// Main instance of the API service to use in the widget. 17 | final ApiService apiService; 18 | 19 | /// The [Status] instance that will be displayed with this widget (initially). 20 | final Status initialStatus; 21 | 22 | const StatusCard(this.initialStatus, {super.key, required this.apiService}); 23 | 24 | @override 25 | State createState() => _StatusCardState(); 26 | } 27 | 28 | class _StatusCardState extends State { 29 | /// The [Status] instance that will be displayed with this widget. 30 | late Status status; 31 | 32 | @override 33 | void initState() { 34 | status = widget.initialStatus; 35 | super.initState(); 36 | } 37 | 38 | /// Makes a call unto the Mastodon API in order to (un)favorite the current 39 | /// toot, and updates the toot's state in the current widget accordingly. 40 | void onFavoritePress() async { 41 | Status newStatus; 42 | 43 | try { 44 | if (status.favorited) { 45 | newStatus = await widget.apiService.undoFavoriteStatus(status.id); 46 | } else { 47 | newStatus = await widget.apiService.favoriteStatus(status.id); 48 | } 49 | } on ApiException { 50 | if (mounted) { 51 | showSnackBar( 52 | context, 53 | "We couldn't perform that action, please try again!", 54 | ); 55 | } 56 | return; 57 | } 58 | 59 | setState(() { 60 | status = newStatus; 61 | }); 62 | } 63 | 64 | /// Makes a call unto the Mastodon API in order to (un)bookmark the current 65 | /// toot, and updates the toot's state in the current widget accordingly. 66 | void onBookmarkPress() async { 67 | Status newStatus; 68 | 69 | try { 70 | if (status.bookmarked) { 71 | newStatus = await widget.apiService.undoBookmarkStatus(status.id); 72 | } else { 73 | newStatus = await widget.apiService.bookmarkStatus(status.id); 74 | } 75 | } on ApiException { 76 | if (mounted) { 77 | showSnackBar( 78 | context, 79 | "We couldn't perform that action, please try again!", 80 | ); 81 | } 82 | return; 83 | } 84 | 85 | setState(() { 86 | status = newStatus; 87 | }); 88 | } 89 | 90 | /// Makes a call unto the Mastodon API in order to (un)boost the current 91 | /// toot, and updates the toot's state in the current widget accordingly. 92 | void onBoostPress() async { 93 | Status newStatus; 94 | 95 | try { 96 | if (status.reblogged) { 97 | newStatus = await widget.apiService.undoBoostStatus(status.id); 98 | } else { 99 | newStatus = await widget.apiService.boostStatus(status.id); 100 | } 101 | } on ApiException { 102 | if (mounted) { 103 | showSnackBar( 104 | context, 105 | "We couldn't perform that action, please try again!", 106 | ); 107 | } 108 | return; 109 | } 110 | 111 | setState(() { 112 | status = newStatus; 113 | }); 114 | } 115 | 116 | // Displays a popup window with the reply screen for the selected toot. 117 | void onReplyPress() { 118 | StatusForm.displayStatusFormWindow( 119 | context, 120 | widget.apiService, 121 | replyToStatus: status, 122 | ); 123 | } 124 | 125 | String getStatusSubtitle() { 126 | String accountHandle = status.account.acct; 127 | 128 | String visibilityIcon = ""; 129 | switch (status.visibility) { 130 | case StatusVisibility.public: 131 | visibilityIcon = "🌍"; 132 | break; 133 | case StatusVisibility.unlisted: 134 | visibilityIcon = "🔒"; 135 | break; 136 | case StatusVisibility.private: 137 | visibilityIcon = "🔐"; 138 | break; 139 | } 140 | 141 | return "$visibilityIcon$accountHandle"; 142 | } 143 | 144 | @override 145 | Widget build(BuildContext context) { 146 | // TODO: display more information on each status 147 | // TODO: main text color (Colors.white) should change depending on theme 148 | return Card( 149 | clipBehavior: Clip.antiAlias, 150 | child: Column( 151 | children: [ 152 | ListTile( 153 | leading: GestureDetector( 154 | onTap: () { 155 | // Navigate to the User screen passing the account object 156 | Navigator.pushNamed( 157 | context, 158 | '/user', 159 | arguments: UserScreenArguments(status.account), 160 | ); 161 | }, 162 | child: CircleAvatar( 163 | foregroundImage: 164 | status.account.avatarUrl != null 165 | ? NetworkImage(status.account.avatarUrl!) 166 | : null, 167 | ), 168 | ), 169 | title: Text( 170 | status.account.displayName != "" 171 | ? status.account.displayName 172 | : status.account.username, 173 | ), 174 | subtitle: Text( 175 | getStatusSubtitle(), 176 | style: TextStyle( 177 | color: Colors.white.withValues(alpha: 0.6), 178 | fontSize: 12.0, 179 | ), 180 | ), 181 | trailing: Text( 182 | status.getRelativeDate(), 183 | style: TextStyle(color: Colors.white.withValues(alpha: 0.6)), 184 | ), 185 | ), 186 | Padding( 187 | padding: const EdgeInsets.all(8.0), 188 | child: Html( 189 | data: status.getContent(), 190 | style: { 191 | 'p': Style(color: Colors.white.withAlpha(153)), 192 | 'a': Style(textDecoration: TextDecoration.none), 193 | }, 194 | // TODO: handle @mentions and #hashtags differently 195 | onLinkTap: 196 | (url, renderContext, attributes) => { 197 | if (url != null) {launchUrl(Uri.parse(url))}, 198 | }, 199 | ), 200 | ), 201 | OverflowBar( 202 | alignment: MainAxisAlignment.spaceAround, 203 | children: [ 204 | Row( 205 | mainAxisSize: MainAxisSize.min, 206 | children: [ 207 | IconButton( 208 | onPressed: onReplyPress, 209 | tooltip: "Reply", 210 | icon: const Icon(FeatherIcons.messageCircle), 211 | color: null, 212 | ), 213 | Text("${status.repliesCount}"), 214 | ], 215 | ), 216 | Row( 217 | mainAxisSize: MainAxisSize.min, 218 | children: [ 219 | IconButton( 220 | onPressed: onBoostPress, 221 | tooltip: "Boost", 222 | icon: const Icon(FeatherIcons.repeat), 223 | color: status.reblogged ? Colors.green : null, 224 | ), 225 | Text("${status.reblogsCount}"), 226 | ], 227 | ), 228 | Row( 229 | mainAxisSize: MainAxisSize.min, 230 | children: [ 231 | IconButton( 232 | onPressed: onFavoritePress, 233 | tooltip: "Favorite", 234 | icon: const Icon(FeatherIcons.star), 235 | color: status.favorited ? Colors.orange : null, 236 | ), 237 | Text("${status.favouritesCount}"), 238 | ], 239 | ), 240 | IconButton( 241 | onPressed: onBookmarkPress, 242 | tooltip: "Bookmark", 243 | icon: const Icon(FeatherIcons.bookmark), 244 | color: status.bookmarked ? Colors.blue : null, 245 | ), 246 | ], 247 | ), 248 | ], 249 | ), 250 | ); 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /lib/widgets/status_form.dart: -------------------------------------------------------------------------------- 1 | import 'package:feathr/services/api.dart'; 2 | import 'package:feathr/utils/messages.dart'; 3 | import 'package:flutter/material.dart'; 4 | 5 | import 'package:feathr/data/status.dart'; 6 | import 'package:feathr/widgets/buttons.dart'; 7 | 8 | /// [StatusForm] is a form widget that allows the user to compose 9 | /// a new status (text input) and submit it to the server. 10 | class StatusForm extends StatefulWidget { 11 | /// A function that performs an action, to be called when the user submits 12 | /// a valid input. 13 | final Function onSuccessfulSubmit; 14 | 15 | /// Main instance of the API service to use in the widget. 16 | final ApiService apiService; 17 | 18 | /// Status that is being replied to, if any. 19 | final Status? replyToStatus; 20 | 21 | const StatusForm({ 22 | required this.apiService, 23 | required this.onSuccessfulSubmit, 24 | this.replyToStatus, 25 | super.key, 26 | }); 27 | 28 | @override 29 | StatusFormState createState() { 30 | return StatusFormState(); 31 | } 32 | 33 | /// Displays a dialog box with a form to post a status. 34 | static void displayStatusFormWindow( 35 | BuildContext context, 36 | ApiService apiService, { 37 | Status? replyToStatus, 38 | }) { 39 | showDialog( 40 | context: context, 41 | builder: (BuildContext context) { 42 | return AlertDialog( 43 | title: const Text( 44 | "Compose a new status", 45 | textAlign: TextAlign.center, 46 | ), 47 | titleTextStyle: const TextStyle(fontSize: 18.0), 48 | content: StatusForm( 49 | apiService: apiService, 50 | replyToStatus: replyToStatus, 51 | onSuccessfulSubmit: () { 52 | // Hide the dialog box 53 | Navigator.of(context).pop(); 54 | 55 | // Show a success message 56 | showSnackBar(context, "Status posted successfully!"); 57 | }, 58 | ), 59 | ); 60 | }, 61 | ); 62 | } 63 | } 64 | 65 | /// The [StatusFormState] class wraps up logic and state for the 66 | /// [StatusForm] screen. 67 | class StatusFormState extends State { 68 | /// Global key that uniquely identifies this form. 69 | final _formKey = GlobalKey(); 70 | 71 | /// Controller for the `status` text field, to preserve and access the 72 | /// value set on the input field on this class. 73 | final statusController = TextEditingController(); 74 | 75 | /// Controller for the `spoilerText` text field, to preserve and access the 76 | /// value set on the input field on this class. 77 | final spoilerTextController = TextEditingController(); 78 | 79 | /// Selected visibility for the status. 80 | StatusVisibility selectedVisibility = StatusVisibility.public; 81 | 82 | @override 83 | Widget build(BuildContext context) { 84 | if (widget.replyToStatus != null) { 85 | // If the status is a reply to someone other than the user, 86 | // set the text field to include the reply-to status. 87 | if (widget.replyToStatus!.account.id != 88 | widget.apiService.currentAccount!.id) { 89 | // Set the text field to include the reply-to status. 90 | statusController.text = '@${widget.replyToStatus!.account.acct} '; 91 | } 92 | } 93 | 94 | String helperText = "What's on your mind?"; 95 | if (widget.replyToStatus != null) { 96 | // If the status is a reply, set the helper text to include the 97 | // reply-to status. 98 | helperText = "Replying to @${widget.replyToStatus!.account.acct}"; 99 | } 100 | 101 | // Build a Form widget using the _formKey created above. 102 | return Form( 103 | key: _formKey, 104 | autovalidateMode: AutovalidateMode.onUserInteraction, 105 | child: Column( 106 | mainAxisSize: MainAxisSize.min, 107 | children: [ 108 | TextFormField( 109 | keyboardType: TextInputType.text, 110 | decoration: InputDecoration(helperText: helperText), 111 | controller: statusController, 112 | validator: 113 | (value) => 114 | value == null || value.isEmpty 115 | ? 'This field should not be empty' 116 | : null, 117 | ), 118 | DropdownButtonFormField( 119 | value: selectedVisibility, 120 | decoration: const InputDecoration(helperText: 'Visibility'), 121 | items: const [ 122 | DropdownMenuItem( 123 | value: StatusVisibility.public, 124 | child: Text('Public'), 125 | ), 126 | DropdownMenuItem( 127 | value: StatusVisibility.unlisted, 128 | child: Text('Unlisted'), 129 | ), 130 | DropdownMenuItem( 131 | value: StatusVisibility.private, 132 | child: Text('Private'), 133 | ), 134 | ], 135 | onChanged: (value) { 136 | setState(() { 137 | selectedVisibility = value!; 138 | }); 139 | }, 140 | ), 141 | TextFormField( 142 | keyboardType: TextInputType.text, 143 | decoration: InputDecoration( 144 | helperText: "Content warning (optional)", 145 | ), 146 | controller: spoilerTextController, 147 | ), 148 | FeathrActionButton( 149 | onPressed: () async { 150 | if (_formKey.currentState!.validate()) { 151 | // Hide the button and show a spinner while the status is being 152 | // submitted 153 | 154 | // Post the status to the server 155 | try { 156 | await widget.apiService.postStatus( 157 | statusController.text, 158 | replyToStatus: widget.replyToStatus, 159 | visibility: selectedVisibility, 160 | spoilerText: spoilerTextController.text, 161 | ); 162 | } catch (e) { 163 | // Show an error message if the status couldn't be posted 164 | if (context.mounted) { 165 | showSnackBar(context, 'Failed to post status: $e'); 166 | } 167 | 168 | return; 169 | } 170 | 171 | // Post was successful, call the success function! 172 | widget.onSuccessfulSubmit(); 173 | } 174 | }, 175 | buttonText: 'Post', 176 | ), 177 | ], 178 | ), 179 | ); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /lib/widgets/timeline.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; 4 | 5 | import 'package:feathr/data/status.dart'; 6 | import 'package:feathr/services/api.dart'; 7 | import 'package:feathr/widgets/status_card.dart'; 8 | import 'package:feathr/utils/messages.dart'; 9 | 10 | /// The [Timeline] widget represents a specific timeline view, able to 11 | /// endlessly scroll and request posts from Mastodon's API using the 12 | /// requested timeline type. 13 | class Timeline extends StatefulWidget { 14 | /// Main instance of the API service to use in the widget. 15 | final ApiService apiService; 16 | 17 | /// Type of timeline posts to be fetched from the API 18 | final TimelineType timelineType; 19 | 20 | /// Icon to be displayed for this timeline within a tabbed timeline view 21 | final Tab tabIcon; 22 | 23 | /// Optional account ID to fetch the timeline for. 24 | final String? accountId; 25 | 26 | const Timeline({ 27 | super.key, 28 | required this.apiService, 29 | required this.timelineType, 30 | required this.tabIcon, 31 | this.accountId, 32 | }); 33 | 34 | @override 35 | TimelineState createState() => TimelineState(); 36 | } 37 | 38 | /// The [TimelineState] class wraps up logic and state for the [Timeline] screen. 39 | class TimelineState extends State { 40 | /// Amount of statuses to be requested from the API on each call. 41 | static const _pageSize = 25; 42 | 43 | /// Controller for the paged list of posts. 44 | final PagingController _pagingController = PagingController( 45 | firstPageKey: null, 46 | invisibleItemsThreshold: 5, 47 | ); 48 | 49 | @override 50 | void initState() { 51 | _pagingController.addPageRequestListener((pageKey) { 52 | _fetchPage(pageKey); 53 | }); 54 | super.initState(); 55 | } 56 | 57 | /// If called, requests a new page of statuses from the Mastodon API. 58 | Future _fetchPage(String? lastStatusId) async { 59 | try { 60 | final List newStatuses = await widget.apiService.getStatusList( 61 | widget.timelineType, 62 | lastStatusId, 63 | _pageSize, 64 | accountId: widget.accountId, 65 | ); 66 | 67 | final isLastPage = newStatuses.length < _pageSize; 68 | if (isLastPage) { 69 | _pagingController.appendLastPage(newStatuses); 70 | } else { 71 | final nextPageKey = newStatuses.last.id; 72 | _pagingController.appendPage(newStatuses, nextPageKey); 73 | } 74 | } catch (error) { 75 | _pagingController.error = error; 76 | 77 | if (mounted) { 78 | showSnackBar( 79 | context, 80 | "An error occurred when trying to load new statuses...", 81 | ); 82 | } 83 | } 84 | } 85 | 86 | @override 87 | Widget build(BuildContext context) { 88 | return Center( 89 | child: RefreshIndicator( 90 | onRefresh: () => Future.sync(_pagingController.refresh), 91 | child: PagedListView( 92 | pagingController: _pagingController, 93 | builderDelegate: PagedChildBuilderDelegate( 94 | itemBuilder: 95 | (context, item, index) => 96 | StatusCard(item, apiService: widget.apiService), 97 | ), 98 | ), 99 | ), 100 | ); 101 | } 102 | 103 | @override 104 | void dispose() { 105 | _pagingController.dispose(); 106 | super.dispose(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /lib/widgets/title.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | /// The [TitleWidget] widget renders a given string with titling format, 4 | /// ideally to be used where the `feathr` app name needs to be displayed. 5 | class TitleWidget extends StatelessWidget { 6 | /// String to render within the widget. 7 | final String title; 8 | 9 | const TitleWidget(this.title, {super.key}); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return Text( 14 | title, 15 | textAlign: TextAlign.center, 16 | style: const TextStyle(fontFamily: "Urbanist", fontSize: 84), 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: feathr 2 | description: A Mastodon client built in Flutter. 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 6 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | version: 0.1.0+1 19 | 20 | environment: 21 | sdk: '>=3.7.0 <4.0.0' 22 | 23 | # Dependencies specify other packages that your package needs in order to work. 24 | # To automatically upgrade your package dependencies to the latest versions 25 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 26 | # dependencies can be manually updated by changing the version numbers below to 27 | # the latest version available on pub.dev. To see which dependencies have newer 28 | # versions available, run `flutter pub outdated`. 29 | dependencies: 30 | flutter: 31 | sdk: flutter 32 | 33 | # The following adds the Cupertino Icons font to your application. 34 | # Use with the CupertinoIcons class for iOS style icons. 35 | cupertino_icons: ^1.0.8 36 | 37 | # Icon collections and helpers 38 | flutter_feather_icons: ^2.0.0+1 39 | flutter_launcher_icons: ^0.14.3 40 | 41 | # Helper to display build information and version within the app 42 | package_info_plus: ^8.3.0 43 | 44 | # Network request helpers 45 | http: ^1.3.0 46 | flutter_secure_storage: ^10.0.0-beta.4 47 | oauth2_client: ^4.2.0 48 | 49 | # Used within the timeline view 50 | # NOTE: consider upgrading to v5 at some point in the future 51 | infinite_scroll_pagination: 4.1.0 52 | 53 | # Used to render the content of statuses (toots) 54 | flutter_html: ^3.0.0 55 | url_launcher: ^6.3.1 56 | 57 | # Form validations 58 | validators: ^3.0.0 59 | relative_time: ^5.0.0 60 | 61 | dev_dependencies: 62 | flutter_test: 63 | sdk: flutter 64 | mockito: ^5.4.6 65 | build_runner: ^2.4.15 66 | test_cov_console: ^0.2.2 67 | 68 | # The "flutter_lints" package below contains a set of recommended lints to 69 | # encourage good coding practices. The lint set provided by the package is 70 | # activated in the `analysis_options.yaml` file located at the root of your 71 | # package. See that file for information about deactivating specific lint 72 | # rules and activating additional ones. 73 | flutter_lints: ^5.0.0 74 | 75 | flutter_launcher_icons: 76 | image_path: "assets/images/feathr-icon.png" 77 | android: true 78 | ios: true 79 | remove_alpha_ios: true 80 | 81 | # For information on the generic Dart part of this file, see the 82 | # following page: https://dart.dev/tools/pub/pubspec 83 | 84 | # The following section is specific to Flutter. 85 | flutter: 86 | 87 | # The following line ensures that the Material Icons font is 88 | # included with your application, so that you can use the icons in 89 | # the material Icons class. 90 | uses-material-design: true 91 | 92 | # To add assets to your application, add an assets section, like this: 93 | assets: 94 | - assets/images/feathr-icon.png 95 | 96 | # An image asset can refer to one or more resolution-specific "variants", see 97 | # https://flutter.dev/assets-and-images/#resolution-aware. 98 | 99 | # For details regarding adding assets from package dependencies, see 100 | # https://flutter.dev/assets-and-images/#from-packages 101 | 102 | # To add custom fonts to your application, add a fonts section here, 103 | # in this "flutter" section. Each entry in this list should have a 104 | # "family" key with the font family name, and a "fonts" key with a 105 | # list giving the asset and other descriptors for the font. For 106 | # example: 107 | # fonts: 108 | # - family: Schyler 109 | # fonts: 110 | # - asset: fonts/Schyler-Regular.ttf 111 | # - asset: fonts/Schyler-Italic.ttf 112 | # style: italic 113 | # - family: Trajan Pro 114 | # fonts: 115 | # - asset: fonts/TrajanPro.ttf 116 | # - asset: fonts/TrajanPro_Bold.ttf 117 | # weight: 700 118 | # 119 | # For details regarding fonts from package dependencies, 120 | # see https://flutter.dev/custom-fonts/#from-packages 121 | fonts: 122 | - family: Urbanist 123 | fonts: 124 | - asset: assets/fonts/Urbanist-Regular.ttf 125 | -------------------------------------------------------------------------------- /test/app_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | 3 | import 'package:feathr/app.dart'; 4 | import 'package:feathr/screens/login.dart'; 5 | import 'package:feathr/screens/timeline_tabs.dart'; 6 | import 'package:feathr/widgets/title.dart'; 7 | 8 | void main() { 9 | testWidgets('Main view shows log-in', (WidgetTester tester) async { 10 | // Build our app and trigger a frame. 11 | await tester.pumpWidget(FeathrApp()); 12 | 13 | // Expect to find our Login view 14 | expect(find.byType(Login), findsOneWidget); 15 | expect(find.byType(TimelineTabs), findsNothing); 16 | 17 | // Expect to find the title of the app 18 | expect(find.byType(TitleWidget), findsOneWidget); 19 | expect(find.text('feathr'), findsOneWidget); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /test/data_test/account_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | 3 | import 'package:feathr/data/account.dart'; 4 | 5 | void main() { 6 | testWidgets('Account is created properly from Json', ( 7 | WidgetTester tester, 8 | ) async { 9 | Map data = { 10 | "id": "this is an id", 11 | "username": "username123", 12 | "display_name": "user display name", 13 | "acct": "username123@domain", 14 | "locked": false, 15 | "bot": true, 16 | "avatar": "avatar-url", 17 | "header": "header-url", 18 | }; 19 | 20 | final account = Account.fromJson(data); 21 | expect(account.id, equals("this is an id")); 22 | expect(account.username, equals("username123")); 23 | expect(account.displayName, equals("user display name")); 24 | expect(account.acct, equals("username123@domain")); 25 | expect(account.isLocked, isFalse); 26 | expect(account.isBot, isTrue); 27 | expect(account.avatarUrl, equals("avatar-url")); 28 | expect(account.headerUrl, equals("header-url")); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /test/data_test/custom_emoji_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | 3 | import 'package:feathr/data/custom_emoji.dart'; 4 | 5 | void main() { 6 | group('CustomEmoji', () { 7 | test('constructor creates an instance with the correct properties', () { 8 | const shortcode = 'thinking'; 9 | const url = 'https://example.com/emoji/thinking.gif'; 10 | const staticUrl = 'https://example.com/emoji/thinking.png'; 11 | 12 | final emoji = CustomEmoji( 13 | shortcode: shortcode, 14 | url: url, 15 | staticUrl: staticUrl, 16 | ); 17 | 18 | expect(emoji.shortcode, shortcode); 19 | expect(emoji.url, url); 20 | expect(emoji.staticUrl, staticUrl); 21 | }); 22 | 23 | test('fromJson creates a CustomEmoji from a valid JSON map', () { 24 | final jsonMap = { 25 | 'shortcode': 'party', 26 | 'url': 'https://example.com/emoji/party.gif', 27 | 'static_url': 'https://example.com/emoji/party.png', 28 | }; 29 | 30 | final emoji = CustomEmoji.fromJson(jsonMap); 31 | 32 | expect(emoji.shortcode, 'party'); 33 | expect(emoji.url, 'https://example.com/emoji/party.gif'); 34 | expect(emoji.staticUrl, 'https://example.com/emoji/party.png'); 35 | }); 36 | 37 | test('fromJson correctly handles JSON keys', () { 38 | final jsonMap = { 39 | 'shortcode': 'smile', 40 | 'url': 'https://mastodon.example/emoji/smile.gif', 41 | 'static_url': 'https://mastodon.example/emoji/smile.png', 42 | 'additional_field': 'should be ignored', 43 | }; 44 | 45 | final emoji = CustomEmoji.fromJson(jsonMap); 46 | 47 | expect(emoji.shortcode, 'smile'); 48 | expect(emoji.url, 'https://mastodon.example/emoji/smile.gif'); 49 | expect(emoji.staticUrl, 'https://mastodon.example/emoji/smile.png'); 50 | }); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /test/data_test/status_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | 3 | import 'package:feathr/data/status.dart'; 4 | 5 | void main() { 6 | const Map testStatusNoReblog = { 7 | "id": "11223344", 8 | "content": "

I am a toot!

", 9 | "created_at": "2025-01-01T00:00:00Z", 10 | "favourited": true, 11 | "bookmarked": false, 12 | "reblogged": true, 13 | "visibility": "public", 14 | "favourites_count": 1, 15 | "reblogs_count": 0, 16 | "replies_count": 0, 17 | "spoiler_text": "", 18 | "account": { 19 | "id": "this is an id", 20 | "username": "username123", 21 | "acct": "username123", 22 | "display_name": "user display name", 23 | "locked": false, 24 | "bot": true, 25 | "avatar": "avatar-url", 26 | "header": "header-url", 27 | }, 28 | "emojis": [ 29 | { 30 | "shortcode": "smile", 31 | "static_url": "https://example.com/static/smile.png", 32 | "url": "https://example.com/smile.png", 33 | }, 34 | ], 35 | "reblog": null, 36 | }; 37 | 38 | const Map testStatusWithReblog = { 39 | "id": "11223344", 40 | "content": "

I am a toot!

", 41 | "created_at": "2025-01-01T00:00:00Z", 42 | "favourited": true, 43 | "bookmarked": false, 44 | "reblogged": true, 45 | "visibility": "public", 46 | "favourites_count": 1, 47 | "reblogs_count": 0, 48 | "replies_count": 0, 49 | "spoiler_text": "", 50 | "account": { 51 | "id": "this is an id", 52 | "username": "username123", 53 | "acct": "username123", 54 | "display_name": "user display name", 55 | "locked": false, 56 | "bot": true, 57 | "avatar": "avatar-url", 58 | "header": "header-url", 59 | }, 60 | "reblog": { 61 | "id": "55667788", 62 | "content": "

I am an internal toot!

", 63 | "created_at": "2025-01-01T00:00:00Z", 64 | "favourited": false, 65 | "bookmarked": true, 66 | "reblogged": false, 67 | "visibility": "public", 68 | "favourites_count": 1, 69 | "replies_count": 0, 70 | "reblogs_count": 0, 71 | "spoiler_text": "", 72 | "account": { 73 | "id": "this is another id", 74 | "username": "username456", 75 | "acct": "username456", 76 | "display_name": "user456 display name", 77 | "locked": false, 78 | "bot": true, 79 | "avatar": "avatar-url-2", 80 | "header": "header-url-2", 81 | }, 82 | }, 83 | }; 84 | 85 | testWidgets('Status is created properly from Json (not a reblog)', ( 86 | WidgetTester tester, 87 | ) async { 88 | final status = Status.fromJson(testStatusNoReblog); 89 | 90 | expect(status.id, equals("11223344")); 91 | expect(status.content, equals("

I am a toot!

")); 92 | expect(status.createdAt, equals(DateTime.parse("2025-01-01T00:00:00Z"))); 93 | expect(status.favorited, isTrue); 94 | expect(status.bookmarked, isFalse); 95 | expect(status.reblogged, isTrue); 96 | expect(status.favouritesCount, equals(1)); 97 | expect(status.repliesCount, equals(0)); 98 | expect(status.reblogsCount, equals(0)); 99 | expect(status.account.id, equals("this is an id")); 100 | expect(status.account.username, equals("username123")); 101 | expect(status.account.displayName, equals("user display name")); 102 | expect(status.account.isLocked, isFalse); 103 | expect(status.account.isBot, isTrue); 104 | expect(status.account.avatarUrl, equals("avatar-url")); 105 | expect(status.account.headerUrl, equals("header-url")); 106 | expect(status.customEmojis.length, equals(1)); 107 | expect(status.customEmojis[0].shortcode, equals("smile")); 108 | expect( 109 | status.customEmojis[0].staticUrl, 110 | equals("https://example.com/static/smile.png"), 111 | ); 112 | expect(status.customEmojis[0].url, equals("https://example.com/smile.png")); 113 | expect(status.reblog, isNull); 114 | }); 115 | 116 | testWidgets('Status is created properly from Json (is a reblog)', ( 117 | WidgetTester tester, 118 | ) async { 119 | final status = Status.fromJson(testStatusWithReblog); 120 | expect(status.id, equals("11223344")); 121 | expect(status.content, equals("

I am a toot!

")); 122 | expect(status.createdAt, equals(DateTime.parse("2025-01-01T00:00:00Z"))); 123 | expect(status.favorited, isTrue); 124 | expect(status.bookmarked, isFalse); 125 | expect(status.reblogged, isTrue); 126 | expect(status.favouritesCount, equals(1)); 127 | expect(status.repliesCount, equals(0)); 128 | expect(status.reblogsCount, equals(0)); 129 | expect(status.account.id, equals("this is an id")); 130 | expect(status.account.username, equals("username123")); 131 | expect(status.account.displayName, equals("user display name")); 132 | expect(status.account.isLocked, isFalse); 133 | expect(status.account.isBot, isTrue); 134 | expect(status.account.avatarUrl, equals("avatar-url")); 135 | expect(status.account.headerUrl, equals("header-url")); 136 | expect(status.reblog, isNotNull); 137 | 138 | final reblog = status.reblog!; 139 | expect(reblog.id, equals("55667788")); 140 | expect(reblog.content, equals("

I am an internal toot!

")); 141 | expect(reblog.favorited, isFalse); 142 | expect(reblog.bookmarked, isTrue); 143 | expect(reblog.reblogged, isFalse); 144 | expect(status.favouritesCount, equals(1)); 145 | expect(status.reblogsCount, equals(0)); 146 | expect(reblog.account.id, equals("this is another id")); 147 | expect(reblog.account.username, equals("username456")); 148 | expect(reblog.account.displayName, equals("user456 display name")); 149 | expect(reblog.account.isLocked, isFalse); 150 | expect(reblog.account.isBot, isTrue); 151 | expect(reblog.account.avatarUrl, equals("avatar-url-2")); 152 | expect(reblog.account.headerUrl, equals("header-url-2")); 153 | expect(reblog.customEmojis, equals([])); 154 | }); 155 | 156 | testWidgets( 157 | 'Status.getContent returns the expected content (internal if reblog)', 158 | (WidgetTester tester) async { 159 | final status_1 = Status.fromJson(testStatusNoReblog); 160 | expect(status_1.content, equals("

I am a toot!

")); 161 | expect(status_1.reblog, isNull); 162 | expect(status_1.getContent(), equals("

I am a toot!

")); 163 | 164 | final status_2 = Status.fromJson(testStatusWithReblog); 165 | expect(status_2.content, equals("

I am a toot!

")); 166 | expect(status_2.reblog, isNotNull); 167 | expect(status_2.reblog!.content, equals("

I am an internal toot!

")); 168 | expect(status_2.reblog!.account.acct, equals("username456")); 169 | expect( 170 | status_2.getContent(), 171 | equals("Reblogged from username456:

I am an internal toot!

"), 172 | ); 173 | }, 174 | ); 175 | 176 | testWidgets('Status.getContent properly handles custom emoji in the content', ( 177 | WidgetTester tester, 178 | ) async { 179 | final statusWithEmoji = Status.fromJson({ 180 | ...testStatusNoReblog, 181 | "content": "

I am a toot! :smile:

", 182 | "emojis": [ 183 | { 184 | "shortcode": "smile", 185 | "static_url": "https://example.com/static/smile.png", 186 | "url": "https://example.com/smile.png", 187 | }, 188 | ], 189 | }); 190 | 191 | expect(statusWithEmoji.content, equals("

I am a toot! :smile:

")); 192 | expect( 193 | statusWithEmoji.getContent(), 194 | equals( 195 | "

I am a toot! \"smile\"

", 196 | ), 197 | ); 198 | }); 199 | } 200 | -------------------------------------------------------------------------------- /test/screens_test/about_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | 4 | import 'package:feathr/screens/about.dart'; 5 | import 'package:package_info_plus/package_info_plus.dart'; 6 | 7 | void main() { 8 | testWidgets('About screen is rendered properly', (WidgetTester tester) async { 9 | // Mocking the package info 10 | PackageInfo.setMockInitialValues( 11 | appName: 'feathr', 12 | packageName: 'space.feathr.app', 13 | version: '1.2.3', 14 | buildNumber: '1', 15 | buildSignature: 'testbuild', 16 | ); 17 | 18 | await tester.pumpWidget(const MaterialApp(home: About())); 19 | 20 | expect(find.byType(About), findsOneWidget); 21 | 22 | expect(find.text('feathr'), findsOneWidget); 23 | expect(find.textContaining('feathr'), findsNWidgets(3)); 24 | expect(find.textContaining('free, open source project'), findsOneWidget); 25 | expect( 26 | find.textContaining('GNU Affero General Public License'), 27 | findsOneWidget, 28 | ); 29 | 30 | await tester.pumpAndSettle(const Duration(milliseconds: 200)); 31 | expect(find.text('Version 1.2.3 (build: 1)'), findsOneWidget); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /test/screens_test/login_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | 4 | import 'package:feathr/screens/login.dart'; 5 | 6 | import '../utils.dart'; 7 | 8 | void main() { 9 | testWidgets('Login screen is rendered correctly', ( 10 | WidgetTester tester, 11 | ) async { 12 | final apiService = getTestApiService(); 13 | 14 | await tester.pumpWidget(MaterialApp(home: Login(apiService: apiService))); 15 | 16 | // Expect to find the app's name 17 | expect(find.text('feathr'), findsOneWidget); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /test/screens_test/tabs_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | 4 | import 'package:feathr/screens/timeline_tabs.dart'; 5 | 6 | import '../utils.dart'; 7 | 8 | void main() { 9 | testWidgets('TimelineTabs wrapper has three tabs', ( 10 | WidgetTester tester, 11 | ) async { 12 | final apiService = getTestApiService(); 13 | await tester.pumpWidget( 14 | MaterialApp(home: TimelineTabs(apiService: apiService)), 15 | ); 16 | 17 | // Expect to find our Tabbed view 18 | expect(find.byType(TimelineTabs), findsOneWidget); 19 | 20 | // Expect to find three tabs 21 | expect(find.text('Home'), findsOneWidget); 22 | expect(find.text('Local'), findsOneWidget); 23 | expect(find.text('Fedi'), findsOneWidget); 24 | 25 | // Expect to find the new status button 26 | expect(find.byIcon(Icons.create_rounded), findsOneWidget); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /test/screens_test/user_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:feathr/widgets/timeline.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | 5 | import 'package:feathr/screens/user.dart'; 6 | 7 | import '../utils.dart'; 8 | 9 | void main() { 10 | testWidgets('User screen displays user information', ( 11 | WidgetTester tester, 12 | ) async { 13 | final apiService = getTestApiService(); 14 | final account = apiService.currentAccount!; 15 | 16 | await tester.pumpWidget( 17 | MaterialApp(home: User(account: account, apiService: apiService)), 18 | ); 19 | 20 | // Expect to find the user's name 21 | expect(find.text(account.displayName), findsOneWidget); 22 | 23 | // Expect to find the user's acct 24 | expect(find.text(account.acct), findsOneWidget); 25 | 26 | // Expect to find a Timeline widget 27 | expect(find.byType(Timeline), findsOneWidget); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /test/services_test/api_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:feathr/data/account.dart'; 2 | import 'package:feathr/data/status.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | 5 | import 'package:http/http.dart' as http; 6 | import 'package:mockito/annotations.dart'; 7 | import 'package:mockito/mockito.dart'; 8 | 9 | import 'package:feathr/services/api.dart'; 10 | import 'package:oauth2_client/oauth2_helper.dart'; 11 | import 'api_test.mocks.dart'; 12 | 13 | @GenerateMocks([http.Client]) 14 | @GenerateMocks([OAuth2Helper]) 15 | void main() { 16 | test('setHelper properly creates an OauthHelper if/when needed', () async { 17 | final apiService = ApiService(); 18 | expect(apiService.oauthClientId, isNull); 19 | expect(apiService.oauthClientSecret, isNull); 20 | expect(apiService.instanceUrl, isNull); 21 | 22 | expect(apiService.helper, isNull); 23 | apiService.setHelper(); 24 | expect(apiService.helper, isNull); 25 | 26 | apiService.instanceUrl = "https://example.org"; 27 | apiService.oauthClientId = "test12345"; 28 | apiService.oauthClientSecret = "test98765"; 29 | apiService.setHelper(); 30 | expect(apiService.helper, isA()); 31 | }); 32 | 33 | group('ApiService.getClientCredentials', () { 34 | test( 35 | 'getClientCredentials stores app credentials on a successful api request', 36 | () async { 37 | final client = MockClient(); 38 | final apiService = ApiService(); 39 | apiService.httpClient = client; 40 | 41 | expect(apiService.oauthClientId, isNull); 42 | expect(apiService.oauthClientSecret, isNull); 43 | 44 | apiService.instanceUrl = "https://example.org"; 45 | when( 46 | client.post( 47 | Uri.parse('https://example.org/api/v1/apps'), 48 | body: { 49 | "client_name": "feathr", 50 | "redirect_uris": 'space.feathr.app://oauth-callback', 51 | "scopes": "read write follow", 52 | "website": "https://feathr.space", 53 | }, 54 | ), 55 | ).thenAnswer( 56 | (_) async => http.Response( 57 | '{"client_id": "12345678", "client_secret": "987654321"}', 58 | 200, 59 | ), 60 | ); 61 | await apiService.getClientCredentials(); 62 | 63 | expect(apiService.oauthClientId, equals("12345678")); 64 | expect(apiService.oauthClientSecret, equals("987654321")); 65 | }, 66 | ); 67 | 68 | test('getClientCredentials handles error cases from the api', () async { 69 | final client = MockClient(); 70 | final apiService = ApiService(); 71 | apiService.httpClient = client; 72 | 73 | expect(apiService.oauthClientId, isNull); 74 | expect(apiService.oauthClientSecret, isNull); 75 | 76 | apiService.instanceUrl = "https://example.org"; 77 | when( 78 | client.post( 79 | Uri.parse('https://example.org/api/v1/apps'), 80 | body: { 81 | "client_name": "feathr", 82 | "redirect_uris": 'space.feathr.app://oauth-callback', 83 | "scopes": "read write follow", 84 | "website": "https://feathr.space", 85 | }, 86 | ), 87 | ).thenAnswer( 88 | (_) async => http.Response('{"error": "Error message"}', 422), 89 | ); 90 | expect( 91 | () async => await apiService.getClientCredentials(), 92 | throwsA(isA()), 93 | ); 94 | 95 | expect(apiService.oauthClientId, isNull); 96 | expect(apiService.oauthClientSecret, isNull); 97 | }); 98 | 99 | test('favoriteStatus performs action successfully', () async { 100 | final mockClient = MockClient(); 101 | final mockHelper = MockOAuth2Helper(); 102 | final apiService = ApiService(); 103 | apiService.helper = mockHelper; 104 | apiService.httpClient = mockClient; 105 | apiService.instanceUrl = "https://example.org"; 106 | 107 | const testStatusId = "12345"; 108 | 109 | when( 110 | mockHelper.post( 111 | "https://example.org/api/v1/statuses/$testStatusId/favourite", 112 | httpClient: mockClient, 113 | ), 114 | ).thenAnswer( 115 | (_) async => http.Response( 116 | '{"id":"$testStatusId","created_at": "2025-01-01T00:00:00Z","visibility":"public","emoji":[],"content":"

I am a toot!

","favourited":true,"bookmarked":false,"reblogged":true,"favourites_count":1,"reblogs_count":3,"replies_count":2,"spoiler_text":"","account":{"id":"this is an id","username":"username123","acct":"username123","display_name":"user display name","locked":false,"bot":true,"avatar":"avatar-url","header":"header-url"}}', 117 | 200, 118 | ), 119 | ); 120 | 121 | final outputStatus = await apiService.favoriteStatus(testStatusId); 122 | expect(outputStatus.id, equals(testStatusId)); 123 | expect(outputStatus.content, equals("

I am a toot!

")); 124 | expect(outputStatus.favorited, isTrue); 125 | expect(outputStatus.bookmarked, isFalse); 126 | expect(outputStatus.reblogged, isTrue); 127 | expect(outputStatus.account.id, equals("this is an id")); 128 | expect(outputStatus.account.username, equals("username123")); 129 | expect(outputStatus.account.displayName, equals("user display name")); 130 | expect(outputStatus.account.isLocked, isFalse); 131 | expect(outputStatus.account.isBot, isTrue); 132 | expect(outputStatus.account.avatarUrl, equals("avatar-url")); 133 | expect(outputStatus.account.headerUrl, equals("header-url")); 134 | expect(outputStatus.favouritesCount, equals(1)); 135 | expect(outputStatus.reblogsCount, equals(3)); 136 | expect(outputStatus.repliesCount, equals(2)); 137 | expect(outputStatus.visibility, equals(StatusVisibility.public)); 138 | }); 139 | 140 | test('favoriteStatus handles api errors as expected', () async { 141 | final mockClient = MockClient(); 142 | final mockHelper = MockOAuth2Helper(); 143 | final apiService = ApiService(); 144 | apiService.helper = mockHelper; 145 | apiService.httpClient = mockClient; 146 | apiService.instanceUrl = "https://example.org"; 147 | 148 | const testStatusId = "12345"; 149 | 150 | when( 151 | mockHelper.post( 152 | "https://example.org/api/v1/statuses/$testStatusId/favourite", 153 | httpClient: mockClient, 154 | ), 155 | ).thenAnswer( 156 | (_) async => http.Response('{"error": "Error message"}', 422), 157 | ); 158 | 159 | expect( 160 | () async => await apiService.favoriteStatus(testStatusId), 161 | throwsA(isA()), 162 | ); 163 | }); 164 | 165 | test('undoFavoriteStatus performs action successfully', () async { 166 | final mockClient = MockClient(); 167 | final mockHelper = MockOAuth2Helper(); 168 | final apiService = ApiService(); 169 | apiService.helper = mockHelper; 170 | apiService.httpClient = mockClient; 171 | apiService.instanceUrl = "https://example.org"; 172 | 173 | const testStatusId = "12345"; 174 | 175 | when( 176 | mockHelper.post( 177 | "https://example.org/api/v1/statuses/$testStatusId/unfavourite", 178 | httpClient: mockClient, 179 | ), 180 | ).thenAnswer( 181 | (_) async => http.Response( 182 | '{"id":"$testStatusId","created_at": "2025-01-01T00:00:00Z","visibility":"private","content":"

I am a toot!

","favourited":false,"bookmarked":false,"reblogged":true,"favourites_count":1,"reblogs_count":3,"replies_count":2,"spoiler_text":"","account":{"id":"this is an id","username":"username123","acct":"username123","display_name":"user display name","locked":false,"bot":true,"avatar":"avatar-url","header":"header-url"}}', 183 | 200, 184 | ), 185 | ); 186 | 187 | final outputStatus = await apiService.undoFavoriteStatus(testStatusId); 188 | expect(outputStatus.id, equals(testStatusId)); 189 | expect(outputStatus.content, equals("

I am a toot!

")); 190 | expect(outputStatus.favorited, isFalse); 191 | expect(outputStatus.bookmarked, isFalse); 192 | expect(outputStatus.reblogged, isTrue); 193 | expect(outputStatus.account.id, equals("this is an id")); 194 | expect(outputStatus.account.username, equals("username123")); 195 | expect(outputStatus.account.displayName, equals("user display name")); 196 | expect(outputStatus.account.isLocked, isFalse); 197 | expect(outputStatus.account.isBot, isTrue); 198 | expect(outputStatus.account.avatarUrl, equals("avatar-url")); 199 | expect(outputStatus.account.headerUrl, equals("header-url")); 200 | expect(outputStatus.visibility, equals(StatusVisibility.private)); 201 | }); 202 | 203 | test('undoFavoriteStatus handles api errors as expected', () async { 204 | final mockClient = MockClient(); 205 | final mockHelper = MockOAuth2Helper(); 206 | final apiService = ApiService(); 207 | apiService.helper = mockHelper; 208 | apiService.httpClient = mockClient; 209 | apiService.instanceUrl = "https://example.org"; 210 | 211 | const testStatusId = "12345"; 212 | 213 | when( 214 | mockHelper.post( 215 | "https://example.org/api/v1/statuses/$testStatusId/unfavourite", 216 | httpClient: mockClient, 217 | ), 218 | ).thenAnswer( 219 | (_) async => http.Response('{"error": "Error message"}', 422), 220 | ); 221 | 222 | expect( 223 | () async => await apiService.undoFavoriteStatus(testStatusId), 224 | throwsA(isA()), 225 | ); 226 | }); 227 | 228 | test('bookmarkStatus performs action successfully', () async { 229 | final mockClient = MockClient(); 230 | final mockHelper = MockOAuth2Helper(); 231 | final apiService = ApiService(); 232 | apiService.helper = mockHelper; 233 | apiService.httpClient = mockClient; 234 | apiService.instanceUrl = "https://example.org"; 235 | 236 | const testStatusId = "12345"; 237 | 238 | when( 239 | mockHelper.post( 240 | "https://example.org/api/v1/statuses/$testStatusId/bookmark", 241 | httpClient: mockClient, 242 | ), 243 | ).thenAnswer( 244 | (_) async => http.Response( 245 | '{"id":"$testStatusId","created_at": "2025-01-01T00:00:00Z","visibility":"unlisted","content":"

I am a toot!

","favourited":true,"bookmarked":true,"reblogged":true,"favourites_count":1,"reblogs_count":3,"replies_count":2,"spoiler_text":"","account":{"id":"this is an id","username":"username123","acct":"username123","display_name":"user display name","locked":false,"bot":true,"avatar":"avatar-url","header":"header-url"}}', 246 | 200, 247 | ), 248 | ); 249 | 250 | final outputStatus = await apiService.bookmarkStatus(testStatusId); 251 | expect(outputStatus.id, equals(testStatusId)); 252 | expect(outputStatus.content, equals("

I am a toot!

")); 253 | expect(outputStatus.favorited, isTrue); 254 | expect(outputStatus.bookmarked, isTrue); 255 | expect(outputStatus.reblogged, isTrue); 256 | expect(outputStatus.account.id, equals("this is an id")); 257 | expect(outputStatus.account.username, equals("username123")); 258 | expect(outputStatus.account.displayName, equals("user display name")); 259 | expect(outputStatus.account.isLocked, isFalse); 260 | expect(outputStatus.account.isBot, isTrue); 261 | expect(outputStatus.account.avatarUrl, equals("avatar-url")); 262 | expect(outputStatus.account.headerUrl, equals("header-url")); 263 | expect(outputStatus.visibility, equals(StatusVisibility.unlisted)); 264 | }); 265 | 266 | test('bookmarkStatus handles api errors as expected', () async { 267 | final mockClient = MockClient(); 268 | final mockHelper = MockOAuth2Helper(); 269 | final apiService = ApiService(); 270 | apiService.helper = mockHelper; 271 | apiService.httpClient = mockClient; 272 | apiService.instanceUrl = "https://example.org"; 273 | 274 | const testStatusId = "12345"; 275 | 276 | when( 277 | mockHelper.post( 278 | "https://example.org/api/v1/statuses/$testStatusId/bookmark", 279 | httpClient: mockClient, 280 | ), 281 | ).thenAnswer( 282 | (_) async => http.Response('{"error": "Error message"}', 422), 283 | ); 284 | 285 | expect( 286 | () async => await apiService.bookmarkStatus(testStatusId), 287 | throwsA(isA()), 288 | ); 289 | }); 290 | 291 | test('undoBookmarkStatus performs action successfully', () async { 292 | final mockClient = MockClient(); 293 | final mockHelper = MockOAuth2Helper(); 294 | final apiService = ApiService(); 295 | apiService.helper = mockHelper; 296 | apiService.httpClient = mockClient; 297 | apiService.instanceUrl = "https://example.org"; 298 | 299 | const testStatusId = "12345"; 300 | 301 | when( 302 | mockHelper.post( 303 | "https://example.org/api/v1/statuses/$testStatusId/unbookmark", 304 | httpClient: mockClient, 305 | ), 306 | ).thenAnswer( 307 | (_) async => http.Response( 308 | '{"id":"$testStatusId","created_at": "2025-01-01T00:00:00Z","visibility":"public","emoji":[],"content":"

I am a toot!

","favourited":false,"bookmarked":false,"reblogged":true,"favourites_count":1,"reblogs_count":3,"replies_count":2,"spoiler_text":"","account":{"id":"this is an id","username":"username123","acct":"username123","display_name":"user display name","locked":false,"bot":true,"avatar":"avatar-url","header":"header-url"}}', 309 | 200, 310 | ), 311 | ); 312 | 313 | final outputStatus = await apiService.undoBookmarkStatus(testStatusId); 314 | expect(outputStatus.id, equals(testStatusId)); 315 | expect(outputStatus.content, equals("

I am a toot!

")); 316 | expect(outputStatus.favorited, isFalse); 317 | expect(outputStatus.bookmarked, isFalse); 318 | expect(outputStatus.reblogged, isTrue); 319 | expect(outputStatus.account.id, equals("this is an id")); 320 | expect(outputStatus.account.username, equals("username123")); 321 | expect(outputStatus.account.displayName, equals("user display name")); 322 | expect(outputStatus.account.isLocked, isFalse); 323 | expect(outputStatus.account.isBot, isTrue); 324 | expect(outputStatus.account.avatarUrl, equals("avatar-url")); 325 | expect(outputStatus.account.headerUrl, equals("header-url")); 326 | }); 327 | 328 | test('undoBookmarkStatus handles api errors as expected', () async { 329 | final mockClient = MockClient(); 330 | final mockHelper = MockOAuth2Helper(); 331 | final apiService = ApiService(); 332 | apiService.helper = mockHelper; 333 | apiService.httpClient = mockClient; 334 | apiService.instanceUrl = "https://example.org"; 335 | 336 | const testStatusId = "12345"; 337 | 338 | when( 339 | mockHelper.post( 340 | "https://example.org/api/v1/statuses/$testStatusId/unbookmark", 341 | httpClient: mockClient, 342 | ), 343 | ).thenAnswer( 344 | (_) async => http.Response('{"error": "Error message"}', 422), 345 | ); 346 | 347 | expect( 348 | () async => await apiService.undoBookmarkStatus(testStatusId), 349 | throwsA(isA()), 350 | ); 351 | }); 352 | 353 | test('boostStatus performs action successfully', () async { 354 | final mockClient = MockClient(); 355 | final mockHelper = MockOAuth2Helper(); 356 | final apiService = ApiService(); 357 | apiService.helper = mockHelper; 358 | apiService.httpClient = mockClient; 359 | apiService.instanceUrl = "https://example.org"; 360 | 361 | const testStatusId = "12345"; 362 | 363 | when( 364 | mockHelper.post( 365 | "https://example.org/api/v1/statuses/$testStatusId/reblog", 366 | httpClient: mockClient, 367 | ), 368 | ).thenAnswer( 369 | (_) async => http.Response( 370 | '{"reblog":{"id":"$testStatusId","created_at": "2025-01-01T00:00:00Z","visibility":"public","emoji":[],"content":"

I am a toot!

","favourited":true,"bookmarked":true,"reblogged":true,"favourites_count":1,"reblogs_count":3,"replies_count":2,"spoiler_text":"","account":{"id":"this is an id","username":"username123","acct":"username123","display_name":"user display name","locked":false,"bot":true,"avatar":"avatar-url","header":"header-url"}}}', 371 | 200, 372 | ), 373 | ); 374 | 375 | final outputStatus = await apiService.boostStatus(testStatusId); 376 | expect(outputStatus.id, equals(testStatusId)); 377 | expect(outputStatus.content, equals("

I am a toot!

")); 378 | expect(outputStatus.favorited, isTrue); 379 | expect(outputStatus.bookmarked, isTrue); 380 | expect(outputStatus.reblogged, isTrue); 381 | expect(outputStatus.account.id, equals("this is an id")); 382 | expect(outputStatus.account.username, equals("username123")); 383 | expect(outputStatus.account.displayName, equals("user display name")); 384 | expect(outputStatus.account.isLocked, isFalse); 385 | expect(outputStatus.account.isBot, isTrue); 386 | expect(outputStatus.account.avatarUrl, equals("avatar-url")); 387 | expect(outputStatus.account.headerUrl, equals("header-url")); 388 | }); 389 | 390 | test('boostStatus handles api errors as expected', () async { 391 | final mockClient = MockClient(); 392 | final mockHelper = MockOAuth2Helper(); 393 | final apiService = ApiService(); 394 | apiService.helper = mockHelper; 395 | apiService.httpClient = mockClient; 396 | apiService.instanceUrl = "https://example.org"; 397 | 398 | const testStatusId = "12345"; 399 | 400 | when( 401 | mockHelper.post( 402 | "https://example.org/api/v1/statuses/$testStatusId/reblog", 403 | httpClient: mockClient, 404 | ), 405 | ).thenAnswer( 406 | (_) async => http.Response('{"error": "Error message"}', 422), 407 | ); 408 | 409 | expect( 410 | () async => await apiService.boostStatus(testStatusId), 411 | throwsA(isA()), 412 | ); 413 | }); 414 | 415 | test('undoBoostStatus performs action successfully', () async { 416 | final mockClient = MockClient(); 417 | final mockHelper = MockOAuth2Helper(); 418 | final apiService = ApiService(); 419 | apiService.helper = mockHelper; 420 | apiService.httpClient = mockClient; 421 | apiService.instanceUrl = "https://example.org"; 422 | 423 | const testStatusId = "12345"; 424 | 425 | when( 426 | mockHelper.post( 427 | "https://example.org/api/v1/statuses/$testStatusId/unreblog", 428 | httpClient: mockClient, 429 | ), 430 | ).thenAnswer( 431 | (_) async => http.Response( 432 | '{"reblog":{"id":"$testStatusId","created_at": "2025-01-01T00:00:00Z","visibility":"public","emoji":[],"content":"

I am a toot!

","favourited":true,"bookmarked":true,"reblogged":true,"favourites_count":1,"reblogs_count":3,"replies_count":2,"spoiler_text":"","account":{"id":"this is an id","username":"username123","acct":"username123","display_name":"user display name","locked":false,"bot":true,"avatar":"avatar-url","header":"header-url"}}}', 433 | 200, 434 | ), 435 | ); 436 | 437 | final outputStatus = await apiService.undoBoostStatus(testStatusId); 438 | expect(outputStatus.id, equals(testStatusId)); 439 | expect(outputStatus.content, equals("

I am a toot!

")); 440 | expect(outputStatus.favorited, isTrue); 441 | expect(outputStatus.bookmarked, isTrue); 442 | expect(outputStatus.reblogged, isTrue); 443 | expect(outputStatus.account.id, equals("this is an id")); 444 | expect(outputStatus.account.username, equals("username123")); 445 | expect(outputStatus.account.displayName, equals("user display name")); 446 | expect(outputStatus.account.isLocked, isFalse); 447 | expect(outputStatus.account.isBot, isTrue); 448 | expect(outputStatus.account.avatarUrl, equals("avatar-url")); 449 | expect(outputStatus.account.headerUrl, equals("header-url")); 450 | }); 451 | 452 | test('undoBoostStatus handles api errors as expected', () async { 453 | final mockClient = MockClient(); 454 | final mockHelper = MockOAuth2Helper(); 455 | final apiService = ApiService(); 456 | apiService.helper = mockHelper; 457 | apiService.httpClient = mockClient; 458 | apiService.instanceUrl = "https://example.org"; 459 | 460 | const testStatusId = "12345"; 461 | 462 | when( 463 | mockHelper.post( 464 | "https://example.org/api/v1/statuses/$testStatusId/unreblog", 465 | httpClient: mockClient, 466 | ), 467 | ).thenAnswer( 468 | (_) async => http.Response('{"error": "Error message"}', 422), 469 | ); 470 | 471 | expect( 472 | () async => await apiService.undoBoostStatus(testStatusId), 473 | throwsA(isA()), 474 | ); 475 | }); 476 | 477 | test('getAccount retrieves user from api successfully', () async { 478 | final mockClient = MockClient(); 479 | final mockHelper = MockOAuth2Helper(); 480 | final apiService = ApiService(); 481 | apiService.helper = mockHelper; 482 | apiService.httpClient = mockClient; 483 | apiService.instanceUrl = "https://example.org"; 484 | 485 | when( 486 | mockHelper.get( 487 | "https://example.org/api/v1/accounts/verify_credentials", 488 | httpClient: mockClient, 489 | ), 490 | ).thenAnswer( 491 | (_) async => http.Response( 492 | '{"id":"this is an id","username":"username123","acct":"username123","display_name":"user display name","locked":false,"bot":true,"avatar":"avatar-url","header":"header-url"}', 493 | 200, 494 | ), 495 | ); 496 | 497 | expect(apiService.currentAccount, isNull); 498 | final outputAccount = await apiService.getAccount(); 499 | expect(apiService.currentAccount, isNotNull); 500 | expect(apiService.currentAccount, equals(outputAccount)); 501 | 502 | expect(outputAccount.id, equals("this is an id")); 503 | expect(outputAccount.username, equals("username123")); 504 | expect(outputAccount.displayName, equals("user display name")); 505 | expect(outputAccount.isLocked, isFalse); 506 | expect(outputAccount.isBot, isTrue); 507 | expect(outputAccount.avatarUrl, equals("avatar-url")); 508 | expect(outputAccount.headerUrl, equals("header-url")); 509 | }); 510 | 511 | test('getAccount handles api errors as expected', () async { 512 | final mockClient = MockClient(); 513 | final mockHelper = MockOAuth2Helper(); 514 | final apiService = ApiService(); 515 | apiService.helper = mockHelper; 516 | apiService.httpClient = mockClient; 517 | apiService.instanceUrl = "https://example.org"; 518 | 519 | when( 520 | mockHelper.get( 521 | "https://example.org/api/v1/accounts/verify_credentials", 522 | httpClient: mockClient, 523 | ), 524 | ).thenAnswer( 525 | (_) async => http.Response('{"error": "Error message"}', 422), 526 | ); 527 | 528 | expect(apiService.currentAccount, isNull); 529 | expect( 530 | () async => await apiService.getAccount(), 531 | throwsA(isA()), 532 | ); 533 | expect(apiService.currentAccount, isNull); 534 | }); 535 | 536 | test('getCurrentAccount retrieves user from cache if exists', () async { 537 | final apiService = ApiService(); 538 | apiService.instanceUrl = "https://example.org"; 539 | 540 | final testAccount = Account( 541 | id: "12345", 542 | username: "test", 543 | displayName: "test username", 544 | acct: "test", 545 | isLocked: false, 546 | isBot: false, 547 | ); 548 | apiService.currentAccount = testAccount; 549 | 550 | // we don't mockup calls so this testt will fail if it tries to call the apis 551 | final outputAccount = await apiService.getCurrentAccount(); 552 | expect(outputAccount, isNotNull); 553 | expect(outputAccount, equals(testAccount)); 554 | }); 555 | 556 | test( 557 | 'getCurrentAccount retrieves user from api when needed successfully', 558 | () async { 559 | final mockClient = MockClient(); 560 | final mockHelper = MockOAuth2Helper(); 561 | final apiService = ApiService(); 562 | apiService.helper = mockHelper; 563 | apiService.httpClient = mockClient; 564 | apiService.instanceUrl = "https://example.org"; 565 | 566 | when( 567 | mockHelper.get( 568 | "https://example.org/api/v1/accounts/verify_credentials", 569 | httpClient: mockClient, 570 | ), 571 | ).thenAnswer( 572 | (_) async => http.Response( 573 | '{"id":"this is an id","username":"username123","acct":"username123","display_name":"user display name","locked":false,"bot":true,"avatar":"avatar-url","header":"header-url"}', 574 | 200, 575 | ), 576 | ); 577 | 578 | expect(apiService.currentAccount, isNull); 579 | final outputAccount = await apiService.getCurrentAccount(); 580 | expect(apiService.currentAccount, isNotNull); 581 | expect(apiService.currentAccount, equals(outputAccount)); 582 | 583 | expect(outputAccount.id, equals("this is an id")); 584 | expect(outputAccount.username, equals("username123")); 585 | expect(outputAccount.displayName, equals("user display name")); 586 | expect(outputAccount.isLocked, isFalse); 587 | expect(outputAccount.isBot, isTrue); 588 | expect(outputAccount.avatarUrl, equals("avatar-url")); 589 | expect(outputAccount.headerUrl, equals("header-url")); 590 | }, 591 | ); 592 | 593 | test('getCurrentAccount handles api errors as expected', () async { 594 | final mockClient = MockClient(); 595 | final mockHelper = MockOAuth2Helper(); 596 | final apiService = ApiService(); 597 | apiService.helper = mockHelper; 598 | apiService.httpClient = mockClient; 599 | apiService.instanceUrl = "https://example.org"; 600 | 601 | when( 602 | mockHelper.get( 603 | "https://example.org/api/v1/accounts/verify_credentials", 604 | httpClient: mockClient, 605 | ), 606 | ).thenAnswer( 607 | (_) async => http.Response('{"error": "Error message"}', 422), 608 | ); 609 | 610 | expect(apiService.currentAccount, isNull); 611 | expect( 612 | () async => await apiService.getCurrentAccount(), 613 | throwsA(isA()), 614 | ); 615 | expect(apiService.currentAccount, isNull); 616 | }); 617 | 618 | test('logIn mirrors behavior of getAccount', () async { 619 | final mockClient = MockClient(); 620 | final mockHelper = MockOAuth2Helper(); 621 | final apiService = ApiService(); 622 | apiService.helper = mockHelper; 623 | apiService.httpClient = mockClient; 624 | apiService.instanceUrl = "https://example.org"; 625 | 626 | when( 627 | mockHelper.get( 628 | "https://example.org/api/v1/accounts/verify_credentials", 629 | httpClient: mockClient, 630 | ), 631 | ).thenAnswer( 632 | (_) async => http.Response( 633 | '{"id":"this is an id","username":"username123","acct":"username123","display_name":"user display name","locked":false,"bot":true,"avatar":"avatar-url","header":"header-url"}', 634 | 200, 635 | ), 636 | ); 637 | 638 | expect(apiService.currentAccount, isNull); 639 | final outputAccount = await apiService.logIn(); 640 | expect(apiService.currentAccount, isNotNull); 641 | expect(apiService.currentAccount, equals(outputAccount)); 642 | 643 | expect(outputAccount.id, equals("this is an id")); 644 | expect(outputAccount.username, equals("username123")); 645 | expect(outputAccount.displayName, equals("user display name")); 646 | expect(outputAccount.isLocked, isFalse); 647 | expect(outputAccount.isBot, isTrue); 648 | expect(outputAccount.avatarUrl, equals("avatar-url")); 649 | expect(outputAccount.headerUrl, equals("header-url")); 650 | }); 651 | 652 | test('getStatusList retrieves a list of statuses from the API', () async { 653 | final mockClient = MockClient(); 654 | final mockHelper = MockOAuth2Helper(); 655 | final apiService = ApiService(); 656 | apiService.helper = mockHelper; 657 | apiService.httpClient = mockClient; 658 | apiService.instanceUrl = "https://example.org"; 659 | 660 | when( 661 | mockHelper.get( 662 | "https://example.org/api/v1/timelines/home?limit=10", 663 | httpClient: mockClient, 664 | ), 665 | ).thenAnswer( 666 | (_) async => http.Response( 667 | '[{"id": "1", "created_at": "2025-01-01T00:00:00Z", "visibility": "public", "emoji": [], "content": "

Status 1

", "favourited": false, "bookmarked": false, "reblogged": false, "favourites_count": 0, "reblogs_count": 0, "replies_count": 2, "spoiler_text": "", "account": {"id": "account1", "username": "user1", "acct": "user1", "display_name": "User One", "locked": false, "bot": false, "avatar": "avatar1-url", "header": "header1-url"}}, {"id": "2", "created_at": "2025-01-02T00:00:00Z", "visibility": "public", "emoji": [], "content": "

Status 2

", "favourited": false, "bookmarked": false, "reblogged": false, "favourites_count": 0, "reblogs_count": 0, "replies_count": 2, "spoiler_text": "", "account": {"id": "account2", "username": "user2", "acct": "user2", "display_name": "User Two", "locked": false, "bot": false, "avatar": "avatar2-url", "header": "header2-url"}}]', 668 | 200, 669 | ), 670 | ); 671 | 672 | final statuses = await apiService.getStatusList( 673 | TimelineType.home, 674 | null, 675 | 10, 676 | ); 677 | 678 | expect(statuses.length, equals(2)); 679 | expect(statuses[0].id, equals("1")); 680 | expect(statuses[1].id, equals("2")); 681 | expect(statuses[0].content, equals("

Status 1

")); 682 | expect(statuses[0].account.username, equals("user1")); 683 | expect(statuses[0].account.displayName, equals("User One")); 684 | expect(statuses[1].content, equals("

Status 2

")); 685 | expect(statuses[1].account.username, equals("user2")); 686 | expect(statuses[1].account.displayName, equals("User Two")); 687 | }); 688 | 689 | test( 690 | 'postStatus posts a new status and returns the created status', 691 | () async { 692 | final mockClient = MockClient(); 693 | final mockHelper = MockOAuth2Helper(); 694 | final apiService = ApiService(); 695 | apiService.helper = mockHelper; 696 | apiService.httpClient = mockClient; 697 | apiService.instanceUrl = "https://example.org"; 698 | 699 | when( 700 | mockHelper.post( 701 | "https://example.org/api/v1/statuses", 702 | body: { 703 | "status": "Hello, world!", 704 | "visibility": "public", 705 | "spoiler_text": "", 706 | }, 707 | httpClient: mockClient, 708 | ), 709 | ).thenAnswer( 710 | (_) async => http.Response( 711 | '{"id": "1", "created_at": "2025-01-01T00:00:00Z", "visibility": "public", "emoji": [], "content": "

Hello, world!

", "favourited": false, "bookmarked": false, "reblogged": false, "favourites_count": 0, "reblogs_count": 0, "replies_count": 2, "spoiler_text": "", "account": {"id": "account1", "username": "user1", "acct": "user1", "display_name": "User One", "locked": false, "bot": false, "avatar": "avatar1-url", "header": "header1-url"}}', 712 | 200, 713 | ), 714 | ); 715 | 716 | final status = await apiService.postStatus("Hello, world!"); 717 | 718 | expect(status.id, equals("1")); 719 | expect(status.content, equals("

Hello, world!

")); 720 | }, 721 | ); 722 | }); 723 | } 724 | -------------------------------------------------------------------------------- /test/services_test/api_test.mocks.dart: -------------------------------------------------------------------------------- 1 | // Mocks generated by Mockito 5.4.2 from annotations 2 | // in feathr/test/services_test/api_test.dart. 3 | // Do not manually edit this file. 4 | 5 | // ignore_for_file: no_leading_underscores_for_library_prefixes 6 | import 'dart:async' as _i7; 7 | import 'dart:convert' as _i8; 8 | import 'dart:typed_data' as _i9; 9 | 10 | import 'package:http/http.dart' as _i2; 11 | import 'package:mockito/mockito.dart' as _i1; 12 | import 'package:oauth2_client/access_token_response.dart' as _i5; 13 | import 'package:oauth2_client/oauth2_client.dart' as _i3; 14 | import 'package:oauth2_client/oauth2_helper.dart' as _i10; 15 | import 'package:oauth2_client/oauth2_response.dart' as _i6; 16 | import 'package:oauth2_client/src/base_web_auth.dart' as _i11; 17 | import 'package:oauth2_client/src/token_storage.dart' as _i4; 18 | 19 | // ignore_for_file: type=lint 20 | // ignore_for_file: avoid_redundant_argument_values 21 | // ignore_for_file: avoid_setters_without_getters 22 | // ignore_for_file: comment_references 23 | // ignore_for_file: implementation_imports 24 | // ignore_for_file: invalid_use_of_visible_for_testing_member 25 | // ignore_for_file: prefer_const_constructors 26 | // ignore_for_file: unnecessary_parenthesis 27 | // ignore_for_file: camel_case_types 28 | // ignore_for_file: subtype_of_sealed_class 29 | 30 | class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { 31 | _FakeResponse_0(Object parent, Invocation parentInvocation) 32 | : super(parent, parentInvocation); 33 | } 34 | 35 | class _FakeStreamedResponse_1 extends _i1.SmartFake 36 | implements _i2.StreamedResponse { 37 | _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) 38 | : super(parent, parentInvocation); 39 | } 40 | 41 | class _FakeOAuth2Client_2 extends _i1.SmartFake implements _i3.OAuth2Client { 42 | _FakeOAuth2Client_2(Object parent, Invocation parentInvocation) 43 | : super(parent, parentInvocation); 44 | } 45 | 46 | class _FakeTokenStorage_3 extends _i1.SmartFake implements _i4.TokenStorage { 47 | _FakeTokenStorage_3(Object parent, Invocation parentInvocation) 48 | : super(parent, parentInvocation); 49 | } 50 | 51 | class _FakeAccessTokenResponse_4 extends _i1.SmartFake 52 | implements _i5.AccessTokenResponse { 53 | _FakeAccessTokenResponse_4(Object parent, Invocation parentInvocation) 54 | : super(parent, parentInvocation); 55 | } 56 | 57 | class _FakeOAuth2Response_5 extends _i1.SmartFake 58 | implements _i6.OAuth2Response { 59 | _FakeOAuth2Response_5(Object parent, Invocation parentInvocation) 60 | : super(parent, parentInvocation); 61 | } 62 | 63 | /// A class which mocks [Client]. 64 | /// 65 | /// See the documentation for Mockito's code generation for more information. 66 | class MockClient extends _i1.Mock implements _i2.Client { 67 | MockClient() { 68 | _i1.throwOnMissingStub(this); 69 | } 70 | 71 | @override 72 | _i7.Future<_i2.Response> head(Uri? url, {Map? headers}) => 73 | (super.noSuchMethod( 74 | Invocation.method(#head, [url], {#headers: headers}), 75 | returnValue: _i7.Future<_i2.Response>.value( 76 | _FakeResponse_0( 77 | this, 78 | Invocation.method(#head, [url], {#headers: headers}), 79 | ), 80 | ), 81 | ) 82 | as _i7.Future<_i2.Response>); 83 | @override 84 | _i7.Future<_i2.Response> get(Uri? url, {Map? headers}) => 85 | (super.noSuchMethod( 86 | Invocation.method(#get, [url], {#headers: headers}), 87 | returnValue: _i7.Future<_i2.Response>.value( 88 | _FakeResponse_0( 89 | this, 90 | Invocation.method(#get, [url], {#headers: headers}), 91 | ), 92 | ), 93 | ) 94 | as _i7.Future<_i2.Response>); 95 | @override 96 | _i7.Future<_i2.Response> post( 97 | Uri? url, { 98 | Map? headers, 99 | Object? body, 100 | _i8.Encoding? encoding, 101 | }) => 102 | (super.noSuchMethod( 103 | Invocation.method( 104 | #post, 105 | [url], 106 | {#headers: headers, #body: body, #encoding: encoding}, 107 | ), 108 | returnValue: _i7.Future<_i2.Response>.value( 109 | _FakeResponse_0( 110 | this, 111 | Invocation.method( 112 | #post, 113 | [url], 114 | {#headers: headers, #body: body, #encoding: encoding}, 115 | ), 116 | ), 117 | ), 118 | ) 119 | as _i7.Future<_i2.Response>); 120 | @override 121 | _i7.Future<_i2.Response> put( 122 | Uri? url, { 123 | Map? headers, 124 | Object? body, 125 | _i8.Encoding? encoding, 126 | }) => 127 | (super.noSuchMethod( 128 | Invocation.method( 129 | #put, 130 | [url], 131 | {#headers: headers, #body: body, #encoding: encoding}, 132 | ), 133 | returnValue: _i7.Future<_i2.Response>.value( 134 | _FakeResponse_0( 135 | this, 136 | Invocation.method( 137 | #put, 138 | [url], 139 | {#headers: headers, #body: body, #encoding: encoding}, 140 | ), 141 | ), 142 | ), 143 | ) 144 | as _i7.Future<_i2.Response>); 145 | @override 146 | _i7.Future<_i2.Response> patch( 147 | Uri? url, { 148 | Map? headers, 149 | Object? body, 150 | _i8.Encoding? encoding, 151 | }) => 152 | (super.noSuchMethod( 153 | Invocation.method( 154 | #patch, 155 | [url], 156 | {#headers: headers, #body: body, #encoding: encoding}, 157 | ), 158 | returnValue: _i7.Future<_i2.Response>.value( 159 | _FakeResponse_0( 160 | this, 161 | Invocation.method( 162 | #patch, 163 | [url], 164 | {#headers: headers, #body: body, #encoding: encoding}, 165 | ), 166 | ), 167 | ), 168 | ) 169 | as _i7.Future<_i2.Response>); 170 | @override 171 | _i7.Future<_i2.Response> delete( 172 | Uri? url, { 173 | Map? headers, 174 | Object? body, 175 | _i8.Encoding? encoding, 176 | }) => 177 | (super.noSuchMethod( 178 | Invocation.method( 179 | #delete, 180 | [url], 181 | {#headers: headers, #body: body, #encoding: encoding}, 182 | ), 183 | returnValue: _i7.Future<_i2.Response>.value( 184 | _FakeResponse_0( 185 | this, 186 | Invocation.method( 187 | #delete, 188 | [url], 189 | {#headers: headers, #body: body, #encoding: encoding}, 190 | ), 191 | ), 192 | ), 193 | ) 194 | as _i7.Future<_i2.Response>); 195 | @override 196 | _i7.Future read(Uri? url, {Map? headers}) => 197 | (super.noSuchMethod( 198 | Invocation.method(#read, [url], {#headers: headers}), 199 | returnValue: _i7.Future.value(''), 200 | ) 201 | as _i7.Future); 202 | @override 203 | _i7.Future<_i9.Uint8List> readBytes( 204 | Uri? url, { 205 | Map? headers, 206 | }) => 207 | (super.noSuchMethod( 208 | Invocation.method(#readBytes, [url], {#headers: headers}), 209 | returnValue: _i7.Future<_i9.Uint8List>.value(_i9.Uint8List(0)), 210 | ) 211 | as _i7.Future<_i9.Uint8List>); 212 | @override 213 | _i7.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => 214 | (super.noSuchMethod( 215 | Invocation.method(#send, [request]), 216 | returnValue: _i7.Future<_i2.StreamedResponse>.value( 217 | _FakeStreamedResponse_1( 218 | this, 219 | Invocation.method(#send, [request]), 220 | ), 221 | ), 222 | ) 223 | as _i7.Future<_i2.StreamedResponse>); 224 | @override 225 | void close() => super.noSuchMethod( 226 | Invocation.method(#close, []), 227 | returnValueForMissingStub: null, 228 | ); 229 | } 230 | 231 | /// A class which mocks [OAuth2Helper]. 232 | /// 233 | /// See the documentation for Mockito's code generation for more information. 234 | class MockOAuth2Helper extends _i1.Mock implements _i10.OAuth2Helper { 235 | MockOAuth2Helper() { 236 | _i1.throwOnMissingStub(this); 237 | } 238 | 239 | @override 240 | _i3.OAuth2Client get client => 241 | (super.noSuchMethod( 242 | Invocation.getter(#client), 243 | returnValue: _FakeOAuth2Client_2(this, Invocation.getter(#client)), 244 | ) 245 | as _i3.OAuth2Client); 246 | @override 247 | _i4.TokenStorage get tokenStorage => 248 | (super.noSuchMethod( 249 | Invocation.getter(#tokenStorage), 250 | returnValue: _FakeTokenStorage_3( 251 | this, 252 | Invocation.getter(#tokenStorage), 253 | ), 254 | ) 255 | as _i4.TokenStorage); 256 | @override 257 | set tokenStorage(_i4.TokenStorage? _tokenStorage) => super.noSuchMethod( 258 | Invocation.setter(#tokenStorage, _tokenStorage), 259 | returnValueForMissingStub: null, 260 | ); 261 | @override 262 | int get grantType => 263 | (super.noSuchMethod(Invocation.getter(#grantType), returnValue: 0) 264 | as int); 265 | @override 266 | set grantType(int? _grantType) => super.noSuchMethod( 267 | Invocation.setter(#grantType, _grantType), 268 | returnValueForMissingStub: null, 269 | ); 270 | @override 271 | String get clientId => 272 | (super.noSuchMethod(Invocation.getter(#clientId), returnValue: '') 273 | as String); 274 | @override 275 | set clientId(String? _clientId) => super.noSuchMethod( 276 | Invocation.setter(#clientId, _clientId), 277 | returnValueForMissingStub: null, 278 | ); 279 | @override 280 | set clientSecret(String? _clientSecret) => super.noSuchMethod( 281 | Invocation.setter(#clientSecret, _clientSecret), 282 | returnValueForMissingStub: null, 283 | ); 284 | @override 285 | set scopes(List? _scopes) => super.noSuchMethod( 286 | Invocation.setter(#scopes, _scopes), 287 | returnValueForMissingStub: null, 288 | ); 289 | @override 290 | bool get enablePKCE => 291 | (super.noSuchMethod(Invocation.getter(#enablePKCE), returnValue: false) 292 | as bool); 293 | @override 294 | set enablePKCE(bool? _enablePKCE) => super.noSuchMethod( 295 | Invocation.setter(#enablePKCE, _enablePKCE), 296 | returnValueForMissingStub: null, 297 | ); 298 | @override 299 | bool get enableState => 300 | (super.noSuchMethod(Invocation.getter(#enableState), returnValue: false) 301 | as bool); 302 | @override 303 | set enableState(bool? _enableState) => super.noSuchMethod( 304 | Invocation.setter(#enableState, _enableState), 305 | returnValueForMissingStub: null, 306 | ); 307 | @override 308 | set afterAuthorizationCodeCb(Function? _afterAuthorizationCodeCb) => 309 | super.noSuchMethod( 310 | Invocation.setter(#afterAuthorizationCodeCb, _afterAuthorizationCodeCb), 311 | returnValueForMissingStub: null, 312 | ); 313 | @override 314 | set authCodeParams(Map? _authCodeParams) => 315 | super.noSuchMethod( 316 | Invocation.setter(#authCodeParams, _authCodeParams), 317 | returnValueForMissingStub: null, 318 | ); 319 | @override 320 | set accessTokenParams(Map? _accessTokenParams) => 321 | super.noSuchMethod( 322 | Invocation.setter(#accessTokenParams, _accessTokenParams), 323 | returnValueForMissingStub: null, 324 | ); 325 | @override 326 | set accessTokenHeaders(Map? _accessTokenHeaders) => 327 | super.noSuchMethod( 328 | Invocation.setter(#accessTokenHeaders, _accessTokenHeaders), 329 | returnValueForMissingStub: null, 330 | ); 331 | @override 332 | set webAuthClient(_i11.BaseWebAuth? _webAuthClient) => super.noSuchMethod( 333 | Invocation.setter(#webAuthClient, _webAuthClient), 334 | returnValueForMissingStub: null, 335 | ); 336 | @override 337 | set webAuthOpts(Map? _webAuthOpts) => super.noSuchMethod( 338 | Invocation.setter(#webAuthOpts, _webAuthOpts), 339 | returnValueForMissingStub: null, 340 | ); 341 | @override 342 | _i7.Future<_i5.AccessTokenResponse?> getToken() => 343 | (super.noSuchMethod( 344 | Invocation.method(#getToken, []), 345 | returnValue: _i7.Future<_i5.AccessTokenResponse?>.value(), 346 | ) 347 | as _i7.Future<_i5.AccessTokenResponse?>); 348 | @override 349 | _i7.Future<_i5.AccessTokenResponse?> getTokenFromStorage() => 350 | (super.noSuchMethod( 351 | Invocation.method(#getTokenFromStorage, []), 352 | returnValue: _i7.Future<_i5.AccessTokenResponse?>.value(), 353 | ) 354 | as _i7.Future<_i5.AccessTokenResponse?>); 355 | @override 356 | _i7.Future<_i5.AccessTokenResponse> fetchToken() => 357 | (super.noSuchMethod( 358 | Invocation.method(#fetchToken, []), 359 | returnValue: _i7.Future<_i5.AccessTokenResponse>.value( 360 | _FakeAccessTokenResponse_4( 361 | this, 362 | Invocation.method(#fetchToken, []), 363 | ), 364 | ), 365 | ) 366 | as _i7.Future<_i5.AccessTokenResponse>); 367 | @override 368 | _i7.Future<_i5.AccessTokenResponse> refreshToken( 369 | _i5.AccessTokenResponse? curTknResp, 370 | ) => 371 | (super.noSuchMethod( 372 | Invocation.method(#refreshToken, [curTknResp]), 373 | returnValue: _i7.Future<_i5.AccessTokenResponse>.value( 374 | _FakeAccessTokenResponse_4( 375 | this, 376 | Invocation.method(#refreshToken, [curTknResp]), 377 | ), 378 | ), 379 | ) 380 | as _i7.Future<_i5.AccessTokenResponse>); 381 | @override 382 | _i7.Future<_i6.OAuth2Response> disconnect({dynamic httpClient}) => 383 | (super.noSuchMethod( 384 | Invocation.method(#disconnect, [], {#httpClient: httpClient}), 385 | returnValue: _i7.Future<_i6.OAuth2Response>.value( 386 | _FakeOAuth2Response_5( 387 | this, 388 | Invocation.method(#disconnect, [], {#httpClient: httpClient}), 389 | ), 390 | ), 391 | ) 392 | as _i7.Future<_i6.OAuth2Response>); 393 | @override 394 | _i7.Future removeAllTokens() => 395 | (super.noSuchMethod( 396 | Invocation.method(#removeAllTokens, []), 397 | returnValue: _i7.Future.value(), 398 | ) 399 | as _i7.Future); 400 | @override 401 | _i7.Future<_i2.Response> post( 402 | String? url, { 403 | Map? headers, 404 | dynamic body, 405 | _i2.Client? httpClient, 406 | }) => 407 | (super.noSuchMethod( 408 | Invocation.method( 409 | #post, 410 | [url], 411 | {#headers: headers, #body: body, #httpClient: httpClient}, 412 | ), 413 | returnValue: _i7.Future<_i2.Response>.value( 414 | _FakeResponse_0( 415 | this, 416 | Invocation.method( 417 | #post, 418 | [url], 419 | {#headers: headers, #body: body, #httpClient: httpClient}, 420 | ), 421 | ), 422 | ), 423 | ) 424 | as _i7.Future<_i2.Response>); 425 | @override 426 | _i7.Future<_i2.Response> put( 427 | String? url, { 428 | Map? headers, 429 | dynamic body, 430 | _i2.Client? httpClient, 431 | }) => 432 | (super.noSuchMethod( 433 | Invocation.method( 434 | #put, 435 | [url], 436 | {#headers: headers, #body: body, #httpClient: httpClient}, 437 | ), 438 | returnValue: _i7.Future<_i2.Response>.value( 439 | _FakeResponse_0( 440 | this, 441 | Invocation.method( 442 | #put, 443 | [url], 444 | {#headers: headers, #body: body, #httpClient: httpClient}, 445 | ), 446 | ), 447 | ), 448 | ) 449 | as _i7.Future<_i2.Response>); 450 | @override 451 | _i7.Future<_i2.Response> patch( 452 | String? url, { 453 | Map? headers, 454 | dynamic body, 455 | _i2.Client? httpClient, 456 | }) => 457 | (super.noSuchMethod( 458 | Invocation.method( 459 | #patch, 460 | [url], 461 | {#headers: headers, #body: body, #httpClient: httpClient}, 462 | ), 463 | returnValue: _i7.Future<_i2.Response>.value( 464 | _FakeResponse_0( 465 | this, 466 | Invocation.method( 467 | #patch, 468 | [url], 469 | {#headers: headers, #body: body, #httpClient: httpClient}, 470 | ), 471 | ), 472 | ), 473 | ) 474 | as _i7.Future<_i2.Response>); 475 | @override 476 | _i7.Future<_i2.Response> get( 477 | String? url, { 478 | Map? headers, 479 | _i2.Client? httpClient, 480 | }) => 481 | (super.noSuchMethod( 482 | Invocation.method( 483 | #get, 484 | [url], 485 | {#headers: headers, #httpClient: httpClient}, 486 | ), 487 | returnValue: _i7.Future<_i2.Response>.value( 488 | _FakeResponse_0( 489 | this, 490 | Invocation.method( 491 | #get, 492 | [url], 493 | {#headers: headers, #httpClient: httpClient}, 494 | ), 495 | ), 496 | ), 497 | ) 498 | as _i7.Future<_i2.Response>); 499 | @override 500 | _i7.Future<_i2.Response> delete( 501 | String? url, { 502 | Map? headers, 503 | _i2.Client? httpClient, 504 | }) => 505 | (super.noSuchMethod( 506 | Invocation.method( 507 | #delete, 508 | [url], 509 | {#headers: headers, #httpClient: httpClient}, 510 | ), 511 | returnValue: _i7.Future<_i2.Response>.value( 512 | _FakeResponse_0( 513 | this, 514 | Invocation.method( 515 | #delete, 516 | [url], 517 | {#headers: headers, #httpClient: httpClient}, 518 | ), 519 | ), 520 | ), 521 | ) 522 | as _i7.Future<_i2.Response>); 523 | @override 524 | _i7.Future<_i2.Response> head( 525 | String? url, { 526 | Map? headers, 527 | dynamic body, 528 | _i2.Client? httpClient, 529 | }) => 530 | (super.noSuchMethod( 531 | Invocation.method( 532 | #head, 533 | [url], 534 | {#headers: headers, #body: body, #httpClient: httpClient}, 535 | ), 536 | returnValue: _i7.Future<_i2.Response>.value( 537 | _FakeResponse_0( 538 | this, 539 | Invocation.method( 540 | #head, 541 | [url], 542 | {#headers: headers, #body: body, #httpClient: httpClient}, 543 | ), 544 | ), 545 | ), 546 | ) 547 | as _i7.Future<_i2.Response>); 548 | } 549 | -------------------------------------------------------------------------------- /test/utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:feathr/data/account.dart'; 2 | import 'package:feathr/services/api.dart'; 3 | 4 | /// Generates and returns a mocked API service instance for tests 5 | ApiService getTestApiService() { 6 | // TODO: mock for further tests 7 | final testService = ApiService(); 8 | testService.currentAccount = Account( 9 | id: "123456", 10 | username: "username", 11 | displayName: "display name", 12 | acct: "username", 13 | isLocked: false, 14 | isBot: false, 15 | ); 16 | return testService; 17 | } 18 | -------------------------------------------------------------------------------- /test/utils_test/messages_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | 4 | import 'package:feathr/utils/messages.dart'; 5 | 6 | void main() { 7 | testWidgets('Messages pop up properly', (WidgetTester tester) async { 8 | GlobalKey key = GlobalKey(); 9 | await tester.pumpWidget( 10 | MaterialApp( 11 | home: Scaffold( 12 | key: key, 13 | body: Center( 14 | child: ElevatedButton( 15 | onPressed: () { 16 | showSnackBar(key.currentContext!, "I am a message!"); 17 | }, 18 | child: const Text("press me"), 19 | ), 20 | ), 21 | ), 22 | ), 23 | ); 24 | 25 | expect(find.text("I am a message!"), findsNothing); 26 | await tester.tap(find.byType(ElevatedButton)); 27 | await tester.pump(const Duration(milliseconds: 100)); 28 | expect(find.text("I am a message!"), findsOneWidget); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /test/widgets_test/buttons_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:feathr/widgets/buttons.dart'; 5 | 6 | void main() { 7 | testWidgets('Action button renders properly', (WidgetTester tester) async { 8 | await tester.pumpWidget( 9 | Directionality( 10 | textDirection: TextDirection.ltr, 11 | child: FeathrActionButton(onPressed: () {}, buttonText: "Click me!"), 12 | ), 13 | ); 14 | expect(find.byType(ElevatedButton), findsOneWidget); 15 | expect(find.text('Click me!'), findsOneWidget); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /test/widgets_test/drawer_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:feathr/data/account.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:feathr/widgets/drawer.dart'; 6 | 7 | void main() { 8 | testWidgets('Drawer header renders properly', (WidgetTester tester) async { 9 | final Account account = Account( 10 | id: '12345678', 11 | username: 'username', 12 | acct: 'username', 13 | displayName: 'display name', 14 | isBot: false, 15 | isLocked: false, 16 | ); 17 | 18 | await tester.pumpWidget( 19 | MaterialApp(home: Drawer(child: FeathrDrawerHeader(account: account))), 20 | ); 21 | expect(find.text('username'), findsOneWidget); 22 | expect(find.text('display name'), findsOneWidget); 23 | expect(find.byType(Text), findsNWidgets(2)); 24 | expect(find.byType(DecorationImage), findsNothing); 25 | expect(find.byType(NetworkImage), findsNothing); 26 | }); 27 | 28 | testWidgets('Drawer header uses default color when headerUrl is null', ( 29 | WidgetTester tester, 30 | ) async { 31 | final Account account = Account( 32 | id: '12345678', 33 | username: 'username', 34 | acct: 'username', 35 | displayName: 'display name', 36 | isBot: false, 37 | isLocked: false, 38 | headerUrl: null, 39 | ); 40 | 41 | await tester.pumpWidget( 42 | MaterialApp(home: Drawer(child: FeathrDrawerHeader(account: account))), 43 | ); 44 | 45 | final userAccountsDrawerHeader = tester.widget( 46 | find.byType(UserAccountsDrawerHeader), 47 | ); 48 | final BoxDecoration? decoration = 49 | userAccountsDrawerHeader.decoration as BoxDecoration?; 50 | 51 | expect(decoration?.color, Colors.teal); 52 | expect(decoration?.image, isNull); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /test/widgets_test/instance_form_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:feathr/widgets/instance_form.dart'; 5 | 6 | void main() { 7 | testWidgets('Instance Form handles invalid data', ( 8 | WidgetTester tester, 9 | ) async { 10 | await tester.pumpWidget( 11 | MaterialApp( 12 | home: Scaffold( 13 | body: Column(children: [InstanceForm(onSuccessfulSubmit: (_) => {})]), 14 | ), 15 | ), 16 | ); 17 | 18 | // Initial render 19 | expect(find.byType(InstanceForm), findsOneWidget); 20 | expect(find.text('Enter a domain, e.g. mastodon.social'), findsOneWidget); 21 | expect(find.text('Log in!'), findsOneWidget); 22 | expect(find.text('This field should not be empty'), findsNothing); 23 | expect(find.text('Please enter a valid URL'), findsNothing); 24 | 25 | // Attempting to log in without a value 26 | await tester.tap(find.text('Log in!')); 27 | await tester.pump(const Duration(milliseconds: 100)); 28 | expect(find.text('This field should not be empty'), findsOneWidget); 29 | expect(find.text('Please enter a valid URL'), findsNothing); 30 | 31 | // Attempt to use an invalid domain 32 | await tester.enterText(find.byType(TextFormField), 'not a url'); 33 | await tester.tap(find.text('Log in!')); 34 | await tester.pump(const Duration(milliseconds: 100)); 35 | expect(find.text('This field should not be empty'), findsNothing); 36 | expect(find.text('Please enter a valid URL'), findsOneWidget); 37 | }); 38 | 39 | testWidgets('Instance Form handles valid data', (WidgetTester tester) async { 40 | await tester.pumpWidget( 41 | MaterialApp( 42 | home: Scaffold( 43 | body: Column(children: [InstanceForm(onSuccessfulSubmit: (_) => {})]), 44 | ), 45 | ), 46 | ); 47 | 48 | // Initial render 49 | expect(find.byType(InstanceForm), findsOneWidget); 50 | expect(find.text('Enter a domain, e.g. mastodon.social'), findsOneWidget); 51 | expect(find.text('Log in!'), findsOneWidget); 52 | expect(find.text('This field should not be empty'), findsNothing); 53 | expect(find.text('Please enter a valid URL'), findsNothing); 54 | 55 | // Attempt to use a valid domain 56 | await tester.enterText(find.byType(TextFormField), 'mastodon.social'); 57 | await tester.tap(find.text('Log in!')); 58 | expect(find.text('This field should not be empty'), findsNothing); 59 | expect(find.text('Please enter a valid URL'), findsNothing); 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /test/widgets_test/status_card_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:feathr/data/status.dart'; 2 | import 'package:feathr/services/api.dart'; 3 | import 'package:flutter_feather_icons/flutter_feather_icons.dart'; 4 | import 'package:flutter_test/flutter_test.dart'; 5 | 6 | import 'package:flutter/material.dart'; 7 | import 'package:feathr/widgets/status_card.dart'; 8 | 9 | import '../utils.dart'; 10 | 11 | void main() { 12 | testWidgets('Status Card is rendered properly', (WidgetTester tester) async { 13 | ApiService apiService = getTestApiService(); 14 | Status status = Status( 15 | id: "12345678", 16 | createdAt: DateTime(2025, 1, 5, 14, 30, 0), 17 | content: "

This is a toot!

", 18 | account: apiService.currentAccount!, 19 | favorited: true, 20 | reblogged: false, 21 | bookmarked: true, 22 | favouritesCount: 10, 23 | reblogsCount: 5, 24 | repliesCount: 2, 25 | visibility: StatusVisibility.public, 26 | spoilerText: "", 27 | customEmojis: [], 28 | ); 29 | 30 | await tester.pumpWidget( 31 | MaterialApp( 32 | home: Scaffold( 33 | body: Column(children: [StatusCard(status, apiService: apiService)]), 34 | ), 35 | ), 36 | ); 37 | 38 | // Initial render 39 | expect(find.byType(StatusCard), findsOneWidget); 40 | expect(find.text('display name'), findsOneWidget); 41 | expect(find.text('🌍username'), findsOneWidget); 42 | expect(find.text('This is a toot!'), findsOneWidget); 43 | expect( 44 | find.widgetWithIcon(IconButton, FeatherIcons.bookmark), 45 | findsOneWidget, 46 | ); 47 | expect(find.widgetWithIcon(IconButton, FeatherIcons.star), findsOneWidget); 48 | expect( 49 | find.widgetWithIcon(IconButton, FeatherIcons.repeat), 50 | findsOneWidget, 51 | ); 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /test/widgets_test/status_form_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:feathr/widgets/status_form.dart'; 4 | import 'package:feathr/data/status.dart'; 5 | import 'package:feathr/data/account.dart'; 6 | 7 | import '../utils.dart'; 8 | 9 | void main() { 10 | testWidgets('Status Form handles invalid data', (WidgetTester tester) async { 11 | await tester.pumpWidget( 12 | MaterialApp( 13 | home: Scaffold( 14 | body: Column( 15 | children: [ 16 | StatusForm( 17 | apiService: getTestApiService(), 18 | onSuccessfulSubmit: (_) => {}, 19 | ), 20 | ], 21 | ), 22 | ), 23 | ), 24 | ); 25 | 26 | // Initial render 27 | expect(find.byType(StatusForm), findsOneWidget); 28 | expect(find.text('What\'s on your mind?'), findsOneWidget); 29 | expect(find.text('Post'), findsOneWidget); 30 | expect(find.text('This field should not be empty'), findsNothing); 31 | expect(find.text('Content warning (optional)'), findsOneWidget); 32 | 33 | // Attempting to post without a value 34 | await tester.tap(find.text('Post')); 35 | await tester.pump(const Duration(milliseconds: 100)); 36 | expect(find.text('This field should not be empty'), findsOneWidget); 37 | }); 38 | 39 | testWidgets('Status Form handles content warning input', ( 40 | WidgetTester tester, 41 | ) async { 42 | await tester.pumpWidget( 43 | MaterialApp( 44 | home: Scaffold( 45 | body: Column( 46 | children: [ 47 | StatusForm( 48 | apiService: getTestApiService(), 49 | onSuccessfulSubmit: () {}, 50 | ), 51 | ], 52 | ), 53 | ), 54 | ), 55 | ); 56 | 57 | // Verify content warning field exists 58 | expect(find.text('Content warning (optional)'), findsOneWidget); 59 | 60 | // Enter content warning text 61 | await tester.enterText( 62 | find.widgetWithText(TextFormField, 'Content warning (optional)'), 63 | 'Test warning', 64 | ); 65 | await tester.pump(); 66 | 67 | // Verify the text was entered 68 | expect(find.text('Test warning'), findsOneWidget); 69 | }); 70 | 71 | testWidgets('Status Form handles visibility selection', ( 72 | WidgetTester tester, 73 | ) async { 74 | await tester.pumpWidget( 75 | MaterialApp( 76 | home: Scaffold( 77 | body: Column( 78 | children: [ 79 | StatusForm( 80 | apiService: getTestApiService(), 81 | onSuccessfulSubmit: () {}, 82 | ), 83 | ], 84 | ), 85 | ), 86 | ), 87 | ); 88 | 89 | // Verify default visibility is public (using first instance which is the selected value) 90 | expect(find.text('Public').first, findsOneWidget); 91 | 92 | // Open dropdown 93 | await tester.tap(find.text('Public').first); 94 | await tester.pumpAndSettle(); 95 | 96 | // Verify all visibility options exist 97 | expect(find.text('Public'), findsWidgets); 98 | expect(find.text('Private'), findsOneWidget); 99 | expect(find.text('Unlisted'), findsOneWidget); 100 | 101 | // Select private visibility (using last instance which is in the dropdown) 102 | await tester.tap(find.text('Private')); 103 | await tester.pumpAndSettle(); 104 | 105 | // Verify selection changed (now only one instance exists) 106 | expect(find.text('Private'), findsOneWidget); 107 | }); 108 | 109 | testWidgets('Status Form handles reply to status', ( 110 | WidgetTester tester, 111 | ) async { 112 | final testAccount = Account( 113 | id: '1', 114 | username: 'testuser', 115 | displayName: 'Test User', 116 | acct: 'testuser', 117 | isLocked: false, 118 | isBot: false, 119 | ); 120 | 121 | final replyToStatus = Status( 122 | id: '1', 123 | content: 'Original status', 124 | account: testAccount, 125 | createdAt: DateTime.now(), 126 | visibility: StatusVisibility.public, 127 | repliesCount: 0, 128 | reblogsCount: 0, 129 | favouritesCount: 0, 130 | favorited: false, 131 | reblogged: false, 132 | bookmarked: false, 133 | spoilerText: '', 134 | customEmojis: [], 135 | ); 136 | 137 | await tester.pumpWidget( 138 | MaterialApp( 139 | home: Scaffold( 140 | body: Column( 141 | children: [ 142 | StatusForm( 143 | apiService: getTestApiService(), 144 | onSuccessfulSubmit: () {}, 145 | replyToStatus: replyToStatus, 146 | ), 147 | ], 148 | ), 149 | ), 150 | ), 151 | ); 152 | 153 | // Verify reply text is shown 154 | expect(find.text('Replying to @testuser'), findsOneWidget); 155 | 156 | // Verify initial text contains mention 157 | final textField = find.byType(TextFormField).first; 158 | final TextFormField textFormField = tester.widget(textField); 159 | expect(textFormField.controller!.text, '@testuser '); 160 | }); 161 | } 162 | -------------------------------------------------------------------------------- /test/widgets_test/title_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:feathr/widgets/title.dart'; 5 | 6 | void main() { 7 | testWidgets('Title widget renders properly', (WidgetTester tester) async { 8 | await tester.pumpWidget( 9 | const Directionality( 10 | textDirection: TextDirection.ltr, 11 | child: TitleWidget("I am a title!"), 12 | ), 13 | ); 14 | expect(find.byType(Text), findsOneWidget); 15 | expect(find.text('I am a title!'), findsOneWidget); 16 | }); 17 | } 18 | --------------------------------------------------------------------------------