├── .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 | 
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 | '
',
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! 
",
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 |
--------------------------------------------------------------------------------