├── lib
├── src
│ ├── screens
│ │ ├── login
│ │ │ ├── components_login.dart
│ │ │ ├── parameters_login.dart
│ │ │ └── base_login.dart
│ │ ├── tabs
│ │ │ ├── components_tabs.dart
│ │ │ ├── parameters_tabs.dart
│ │ │ ├── base_tabs.dart
│ │ │ └── controller_tabs.dart
│ │ ├── boards
│ │ │ ├── components_boards.dart
│ │ │ ├── controller_boards.dart
│ │ │ ├── screen_boards.dart
│ │ │ └── base_boards.dart
│ │ ├── profile
│ │ │ ├── components_profile.dart
│ │ │ ├── parameters_profile.dart
│ │ │ └── base_profile.dart
│ │ ├── splash
│ │ │ ├── components_splash.dart
│ │ │ ├── parameters_splash.dart
│ │ │ ├── screen_splash.dart
│ │ │ ├── base_splash.dart
│ │ │ └── controller_splash.dart
│ │ ├── pipelines
│ │ │ ├── components_pipelines.dart
│ │ │ └── parameters_pipelines.dart
│ │ ├── file_detail
│ │ │ ├── components_file_detail.dart
│ │ │ ├── parameters_file_detail.dart
│ │ │ ├── base_file_detail.dart
│ │ │ └── controller_file_detail.dart
│ │ ├── commit_detail
│ │ │ ├── components_commit_detail.dart
│ │ │ ├── parameters_commit_detail.dart
│ │ │ └── base_commit_detail.dart
│ │ ├── member_detail
│ │ │ ├── components_member_detail.dart
│ │ │ ├── parameters_member_detail.dart
│ │ │ ├── controller_member_detail.dart
│ │ │ └── base_member_detail.dart
│ │ ├── pipeline_logs
│ │ │ ├── components_pipeline_logs.dart
│ │ │ ├── parameters_pipeline_logs.dart
│ │ │ ├── base_pipeline_logs.dart
│ │ │ ├── controller_pipeline_logs.dart
│ │ │ └── screen_pipeline_logs.dart
│ │ ├── pull_requests
│ │ │ ├── components_pull_requests.dart
│ │ │ ├── parameters_pull_requests.dart
│ │ │ └── base_pull_requests.dart
│ │ ├── saved_queries
│ │ │ ├── components_saved_queries.dart
│ │ │ ├── base_saved_queries.dart
│ │ │ ├── controller_saved_queries.dart
│ │ │ └── screen_saved_queries.dart
│ │ ├── choose_projects
│ │ │ ├── components_choose_projects.dart
│ │ │ ├── parameters_choose_projects.dart
│ │ │ └── base_choose_projects.dart
│ │ ├── project_boards
│ │ │ ├── components_project_boards.dart
│ │ │ ├── base_project_boards.dart
│ │ │ └── controller_project_boards.dart
│ │ ├── commits
│ │ │ ├── parameters_commits.dart
│ │ │ └── base_commits.dart
│ │ ├── file_diff
│ │ │ ├── parameters_file_diff.dart
│ │ │ ├── screen_file_diff.dart
│ │ │ └── base_file_diff.dart
│ │ ├── settings
│ │ │ ├── parameters_settings.dart
│ │ │ └── base_settings.dart
│ │ ├── work_items
│ │ │ └── parameters_work_items.dart
│ │ ├── pipeline_detail
│ │ │ └── parameters_pipeline_detail.dart
│ │ ├── work_item_detail
│ │ │ └── parameters_work_item_detail.dart
│ │ ├── repository_detail
│ │ │ ├── parameters_repository_detail.dart
│ │ │ ├── components_repository_detail.dart
│ │ │ ├── base_repository_detail.dart
│ │ │ └── controller_repository_detail.dart
│ │ ├── pull_request_detail
│ │ │ └── parameters_pull_request_detail.dart
│ │ ├── create_or_edit_work_item
│ │ │ └── parameters_create_or_edit_work_item.dart
│ │ ├── home
│ │ │ └── parameters_home.dart
│ │ ├── project_detail
│ │ │ ├── parameters_project_detail.dart
│ │ │ ├── components_project_detail.dart
│ │ │ └── base_project_detail.dart
│ │ ├── sprint_detail
│ │ │ ├── screen_sprint_detail.dart
│ │ │ └── base_sprint_detail.dart
│ │ ├── board_detail
│ │ │ ├── screen_board_detail.dart
│ │ │ └── base_board_detail.dart
│ │ └── choose_subscription
│ │ │ ├── base_choose_subscription.dart
│ │ │ └── controller_choose_subscription.dart
│ ├── extensions
│ │ ├── pipeline_extension.dart
│ │ ├── duration_extension.dart
│ │ ├── child_query_extension.dart
│ │ ├── reponse_extension.dart
│ │ ├── num_extension.dart
│ │ ├── commit_extension.dart
│ │ ├── string_extension.dart
│ │ ├── area_or_iteration_extension.dart
│ │ ├── context_extension.dart
│ │ ├── work_item_relation_extension.dart
│ │ ├── datetime_extension.dart
│ │ ├── work_item_update_extension.dart
│ │ └── approval_extension.dart
│ ├── utils
│ │ └── utils.dart
│ ├── widgets
│ │ ├── shortcut_label.dart
│ │ ├── work_item_type_icon.dart
│ │ ├── text_title_description.dart
│ │ ├── markdown_widget.dart
│ │ ├── empty_page.dart
│ │ ├── error_page.dart
│ │ ├── work_card.dart
│ │ ├── navigation_button.dart
│ │ ├── app_base_page.dart
│ │ ├── pipeline_in_progress_animated_icon.dart
│ │ ├── member_avatar.dart
│ │ ├── section_header.dart
│ │ ├── project_card.dart
│ │ ├── project_and_repo_chips.dart
│ │ ├── loading_button.dart
│ │ ├── lifecycle_listener.dart
│ │ └── popup_menu.dart
│ ├── bindings
│ │ └── fix_ipad_popup_autoclose_binding.dart
│ ├── mixins
│ │ ├── share_mixin.dart
│ │ ├── ads_mixin.dart
│ │ └── logger_mixin.dart
│ ├── models
│ │ ├── team_settings.dart
│ │ ├── backlog.dart
│ │ ├── work_item_type_with_transitions.dart
│ │ ├── work_item_tags.dart
│ │ ├── shared.dart
│ │ ├── team_areas.dart
│ │ ├── identity_response.dart
│ │ ├── team.dart
│ │ ├── repository_items.dart
│ │ ├── commit_detail.dart
│ │ ├── organization.dart
│ │ ├── amazon
│ │ │ └── amazon_item.dart
│ │ └── commits_tags.dart
│ └── services
│ │ ├── share_intent_service.dart
│ │ ├── amazon_service.dart
│ │ └── msal_service.dart
└── firebase_options.dart
├── ios
├── Runner
│ ├── Runner-Bridging-Header.h
│ ├── Assets.xcassets
│ │ ├── LaunchImage.imageset
│ │ │ ├── LaunchImage.png
│ │ │ ├── LaunchImage@2x.png
│ │ │ ├── LaunchImage@3x.png
│ │ │ ├── README.md
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ ├── 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-50x50@1x.png
│ │ │ ├── Icon-App-50x50@2x.png
│ │ │ ├── Icon-App-57x57@1x.png
│ │ │ ├── Icon-App-57x57@2x.png
│ │ │ ├── Icon-App-60x60@2x.png
│ │ │ ├── Icon-App-60x60@3x.png
│ │ │ ├── Icon-App-72x72@1x.png
│ │ │ ├── Icon-App-72x72@2x.png
│ │ │ ├── Icon-App-76x76@1x.png
│ │ │ ├── Icon-App-76x76@2x.png
│ │ │ ├── Icon-App-1024x1024@1x.png
│ │ │ └── Icon-App-83.5x83.5@2x.png
│ ├── Runner.entitlements
│ ├── AppDelegate.swift
│ └── Base.lproj
│ │ └── Main.storyboard
├── Flutter
│ ├── Debug.xcconfig
│ ├── Release.xcconfig
│ └── AppFrameworkInfo.plist
├── Runner.xcodeproj
│ └── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ ├── WorkspaceSettings.xcsettings
│ │ └── IDEWorkspaceChecks.plist
├── Runner.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── WorkspaceSettings.xcsettings
│ │ └── IDEWorkspaceChecks.plist
├── .gitignore
├── dart_define_build_script.sh
├── AzDevopsShareExt
│ ├── Info.plist
│ └── Base.lproj
│ │ └── MainInterface.storyboard
└── Podfile
├── .github
├── FUNDING.yml
└── workflows
│ └── analyze-and-test.yml
├── assets
├── logos
│ └── logo.png
├── fonts
│ └── DevOpsIcons.ttf
├── illustrations
│ ├── empty.png
│ ├── error.png
│ └── crying_smiling_guy.png
└── app_icon
│ ├── app_icon_ios.png
│ └── app_icon_android.png
├── test
├── goldens
│ ├── commits.png
│ └── pipelines.png
├── project_detail_test.dart
├── member_detail_test.dart
├── commit_detail_test.dart
├── repository_detail_test.dart
├── pipeline_logs_test.dart
├── file_detail_test.dart
├── work_items_test.dart
├── pipeline_detail_test.dart
├── work_item_detail_test.dart
├── pull_request_detail_test.dart
├── commits_test.dart
├── pipelines_test.dart
└── choose_projects_test.dart
├── analysis_options.yaml
├── android
├── app
│ ├── src
│ │ └── main
│ │ │ └── res
│ │ │ ├── mipmap-hdpi
│ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-mdpi
│ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-xhdpi
│ │ │ └── ic_launcher.png
│ │ │ ├── drawable
│ │ │ ├── transparent_image.png
│ │ │ └── launch_background.xml
│ │ │ ├── mipmap-xxhdpi
│ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ └── ic_launcher.png
│ │ │ ├── drawable-hdpi
│ │ │ └── ic_launcher_foreground.png
│ │ │ ├── drawable-mdpi
│ │ │ └── ic_launcher_foreground.png
│ │ │ ├── drawable-xhdpi
│ │ │ └── ic_launcher_foreground.png
│ │ │ ├── drawable-xxhdpi
│ │ │ └── ic_launcher_foreground.png
│ │ │ ├── drawable-xxxhdpi
│ │ │ └── ic_launcher_foreground.png
│ │ │ ├── values
│ │ │ ├── colors.xml
│ │ │ └── styles.xml
│ │ │ ├── values-night
│ │ │ ├── colors.xml
│ │ │ └── styles.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ └── ic_launcher.xml
│ │ │ └── drawable-v21
│ │ │ └── launch_background.xml
│ ├── proguard-rules.pro
│ └── google-services.json
├── gradle.properties
├── gradle
│ └── wrapper
│ │ └── gradle-wrapper.properties
├── .gitignore
├── build.gradle
└── settings.gradle
├── .gitignore
├── .metadata
├── LICENSE
├── README.md
└── pubspec.yaml
/lib/src/screens/login/components_login.dart:
--------------------------------------------------------------------------------
1 | part of login;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/tabs/components_tabs.dart:
--------------------------------------------------------------------------------
1 | part of tabs;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/boards/components_boards.dart:
--------------------------------------------------------------------------------
1 | part of boards;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/profile/components_profile.dart:
--------------------------------------------------------------------------------
1 | part of profile;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/splash/components_splash.dart:
--------------------------------------------------------------------------------
1 | part of splash;
2 |
--------------------------------------------------------------------------------
/ios/Runner/Runner-Bridging-Header.h:
--------------------------------------------------------------------------------
1 | #import "GeneratedPluginRegistrant.h"
2 |
--------------------------------------------------------------------------------
/lib/src/screens/pipelines/components_pipelines.dart:
--------------------------------------------------------------------------------
1 | part of pipelines;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/file_detail/components_file_detail.dart:
--------------------------------------------------------------------------------
1 | part of file_detail;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/commit_detail/components_commit_detail.dart:
--------------------------------------------------------------------------------
1 | part of commit_detail;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/member_detail/components_member_detail.dart:
--------------------------------------------------------------------------------
1 | part of member_detail;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/pipeline_logs/components_pipeline_logs.dart:
--------------------------------------------------------------------------------
1 | part of pipeline_logs;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/pull_requests/components_pull_requests.dart:
--------------------------------------------------------------------------------
1 | part of pull_requests;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/saved_queries/components_saved_queries.dart:
--------------------------------------------------------------------------------
1 | part of saved_queries;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/choose_projects/components_choose_projects.dart:
--------------------------------------------------------------------------------
1 | part of choose_projects;
2 |
--------------------------------------------------------------------------------
/lib/src/screens/project_boards/components_project_boards.dart:
--------------------------------------------------------------------------------
1 | part of project_boards;
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: PurpleSoftSrl
4 |
--------------------------------------------------------------------------------
/assets/logos/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/assets/logos/logo.png
--------------------------------------------------------------------------------
/test/goldens/commits.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/test/goldens/commits.png
--------------------------------------------------------------------------------
/test/goldens/pipelines.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/test/goldens/pipelines.png
--------------------------------------------------------------------------------
/assets/fonts/DevOpsIcons.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/assets/fonts/DevOpsIcons.ttf
--------------------------------------------------------------------------------
/assets/illustrations/empty.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/assets/illustrations/empty.png
--------------------------------------------------------------------------------
/assets/illustrations/error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/assets/illustrations/error.png
--------------------------------------------------------------------------------
/assets/app_icon/app_icon_ios.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/assets/app_icon/app_icon_ios.png
--------------------------------------------------------------------------------
/assets/app_icon/app_icon_android.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/assets/app_icon/app_icon_android.png
--------------------------------------------------------------------------------
/lib/src/screens/login/parameters_login.dart:
--------------------------------------------------------------------------------
1 | part of login;
2 |
3 | class _LoginParameters {
4 | const _LoginParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/splash/parameters_splash.dart:
--------------------------------------------------------------------------------
1 | part of splash;
2 |
3 | class _SplashParameters {
4 | const _SplashParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/commits/parameters_commits.dart:
--------------------------------------------------------------------------------
1 | part of commits;
2 |
3 | class _CommitsParameters {
4 | const _CommitsParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/profile/parameters_profile.dart:
--------------------------------------------------------------------------------
1 | part of profile;
2 |
3 | class _ProfileParameters {
4 | const _ProfileParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/analysis_options.yaml:
--------------------------------------------------------------------------------
1 | include: package:purple_lints/all.yaml
2 |
3 | analyzer:
4 | errors:
5 | library_private_types_in_public_api: ignore
6 |
--------------------------------------------------------------------------------
/assets/illustrations/crying_smiling_guy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/assets/illustrations/crying_smiling_guy.png
--------------------------------------------------------------------------------
/lib/src/screens/file_diff/parameters_file_diff.dart:
--------------------------------------------------------------------------------
1 | part of file_diff;
2 |
3 | class _FileDiffParameters {
4 | const _FileDiffParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/settings/parameters_settings.dart:
--------------------------------------------------------------------------------
1 | part of settings;
2 |
3 | class _SettingsParameters {
4 | const _SettingsParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/pipelines/parameters_pipelines.dart:
--------------------------------------------------------------------------------
1 | part of pipelines;
2 |
3 | class _PipelinesParameters {
4 | const _PipelinesParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/work_items/parameters_work_items.dart:
--------------------------------------------------------------------------------
1 | part of work_items;
2 |
3 | class _WorkItemsParameters {
4 | const _WorkItemsParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/file_detail/parameters_file_detail.dart:
--------------------------------------------------------------------------------
1 | part of file_detail;
2 |
3 | class _FileDetailParameters {
4 | const _FileDetailParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/lib/src/screens/commit_detail/parameters_commit_detail.dart:
--------------------------------------------------------------------------------
1 | part of commit_detail;
2 |
3 | class _CommitDetailParameters {
4 | const _CommitDetailParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/member_detail/parameters_member_detail.dart:
--------------------------------------------------------------------------------
1 | part of member_detail;
2 |
3 | class _MemberDetailParameters {
4 | const _MemberDetailParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/pipeline_logs/parameters_pipeline_logs.dart:
--------------------------------------------------------------------------------
1 | part of pipeline_logs;
2 |
3 | class _PipelineLogsParameters {
4 | const _PipelineLogsParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/pull_requests/parameters_pull_requests.dart:
--------------------------------------------------------------------------------
1 | part of pull_requests;
2 |
3 | class _PullRequestsParameters {
4 | const _PullRequestsParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable/transparent_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/drawable/transparent_image.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/ios/Flutter/Debug.xcconfig:
--------------------------------------------------------------------------------
1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
2 | #include "Generated.xcconfig"
3 | #include "EnvironmentDebug.xcconfig"
--------------------------------------------------------------------------------
/lib/src/screens/choose_projects/parameters_choose_projects.dart:
--------------------------------------------------------------------------------
1 | part of choose_projects;
2 |
3 | class _ChooseProjectsParameters {
4 | const _ChooseProjectsParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/pipeline_detail/parameters_pipeline_detail.dart:
--------------------------------------------------------------------------------
1 | part of pipeline_detail;
2 |
3 | class _PipelineDetailParameters {
4 | const _PipelineDetailParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/ios/Flutter/Release.xcconfig:
--------------------------------------------------------------------------------
1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
2 | #include "Generated.xcconfig"
3 | #include "EnvironmentRelease.xcconfig"
4 |
--------------------------------------------------------------------------------
/lib/src/screens/work_item_detail/parameters_work_item_detail.dart:
--------------------------------------------------------------------------------
1 | part of work_item_detail;
2 |
3 | class _WorkItemDetailParameters {
4 | const _WorkItemDetailParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
--------------------------------------------------------------------------------
/lib/src/screens/repository_detail/parameters_repository_detail.dart:
--------------------------------------------------------------------------------
1 | part of repository_detail;
2 |
3 | class _RepositoryDetailParameters {
4 | const _RepositoryDetailParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android/gradle.properties:
--------------------------------------------------------------------------------
1 | org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
2 | android.useAndroidX=true
3 | android.enableJetifier=true
4 |
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/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/PurpleSoftSrl/azure_devops_app/HEAD/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/PurpleSoftSrl/azure_devops_app/HEAD/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/PurpleSoftSrl/azure_devops_app/HEAD/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/PurpleSoftSrl/azure_devops_app/HEAD/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/PurpleSoftSrl/azure_devops_app/HEAD/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/PurpleSoftSrl/azure_devops_app/HEAD/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/PurpleSoftSrl/azure_devops_app/HEAD/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/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/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/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/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/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
--------------------------------------------------------------------------------
/lib/src/screens/pull_request_detail/parameters_pull_request_detail.dart:
--------------------------------------------------------------------------------
1 | part of pull_request_detail;
2 |
3 | class _PullRequestDetailParameters {
4 | const _PullRequestDetailParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
--------------------------------------------------------------------------------
/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PurpleSoftSrl/azure_devops_app/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
--------------------------------------------------------------------------------
/android/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #201F1E
4 | #FFFFFF
5 |
--------------------------------------------------------------------------------
/lib/src/screens/create_or_edit_work_item/parameters_create_or_edit_work_item.dart:
--------------------------------------------------------------------------------
1 | part of create_or_edit_work_item;
2 |
3 | class _CreateOrEditWorkItemParameters {
4 | const _CreateOrEditWorkItemParameters();
5 | }
6 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values-night/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #201F1E
4 | #201F1E
5 |
--------------------------------------------------------------------------------
/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/lib/src/extensions/pipeline_extension.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/models/pipeline.dart';
2 |
3 | extension BuildExt on Pipeline {
4 | String? get sourceBranchShort => sourceBranch?.replaceFirst('refs/heads/', '');
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/extensions/duration_extension.dart:
--------------------------------------------------------------------------------
1 | extension DurationExt on Duration {
2 | String get toMinutes {
3 | final seconds = inSeconds % 60;
4 | return inMinutes <= 0 ? '$seconds s' : '$inMinutes m $seconds s';
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/lib/src/screens/tabs/parameters_tabs.dart:
--------------------------------------------------------------------------------
1 | part of tabs;
2 |
3 | class _TabsParameters {
4 | const _TabsParameters({required this.tabBarHeight, this.tabIconHeight});
5 |
6 | final double tabBarHeight;
7 | final double? tabIconHeight;
8 | }
9 |
--------------------------------------------------------------------------------
/android/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | zipStoreBase=GRADLE_USER_HOME
4 | zipStorePath=wrapper/dists
5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
6 |
--------------------------------------------------------------------------------
/lib/src/extensions/child_query_extension.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/models/saved_query.dart';
2 |
3 | extension ChildQueryExt on ChildQuery {
4 | String get projectId => url.isEmpty ? '' : url.substring(0, url.indexOf('/_apis/')).split('/').last;
5 | }
6 |
--------------------------------------------------------------------------------
/lib/src/screens/home/parameters_home.dart:
--------------------------------------------------------------------------------
1 | part of home;
2 |
3 | class _HomeParameters {
4 | const _HomeParameters({required this.gridItemAspectRatio, this.projectCardHeight});
5 |
6 | final double gridItemAspectRatio;
7 | final double? projectCardHeight;
8 | }
9 |
--------------------------------------------------------------------------------
/lib/src/extensions/reponse_extension.dart:
--------------------------------------------------------------------------------
1 | import 'dart:io';
2 |
3 | import 'package:http/http.dart';
4 |
5 | extension ResponseExt on Response {
6 | bool get isError =>
7 | ![HttpStatus.ok, HttpStatus.created, HttpStatus.noContent, HttpStatus.partialContent].contains(statusCode);
8 | }
9 |
--------------------------------------------------------------------------------
/ios/Runner.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | PreviewsEnabled
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/lib/src/screens/project_detail/parameters_project_detail.dart:
--------------------------------------------------------------------------------
1 | part of project_detail;
2 |
3 | class _ProjectDetailParameters {
4 | const _ProjectDetailParameters({required this.gridItemAspectRatio, required this.memberAvatarSize});
5 |
6 | final double gridItemAspectRatio;
7 | final double memberAvatarSize;
8 | }
9 |
--------------------------------------------------------------------------------
/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | PreviewsEnabled
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/lib/src/extensions/num_extension.dart:
--------------------------------------------------------------------------------
1 | import 'package:intl/intl.dart';
2 |
3 | extension NumExt on num {
4 | String get formatted => (this == toInt() ? toInt() : this).toString();
5 |
6 | String toCurrency(String currency) => NumberFormat.simpleCurrency(name: currency).format(this);
7 |
8 | String toPercentage() => '$this%';
9 | }
10 |
--------------------------------------------------------------------------------
/ios/Runner/Runner.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | keychain-access-groups
6 |
7 | $(AppIdentifierPrefix)com.microsoft.adalcache
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/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 |
15 | app/.cxx/
16 | .kotlin/
--------------------------------------------------------------------------------
/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.
--------------------------------------------------------------------------------
/android/build.gradle:
--------------------------------------------------------------------------------
1 | allprojects {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | }
6 | }
7 |
8 | rootProject.buildDir = '../build'
9 | subprojects {
10 | project.buildDir = "${rootProject.buildDir}/${project.name}"
11 | }
12 | subprojects {
13 | project.evaluationDependsOn(':app')
14 | }
15 |
16 | tasks.register("clean", Delete) {
17 | delete rootProject.buildDir
18 | }
19 |
--------------------------------------------------------------------------------
/lib/src/utils/utils.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/router/router.dart';
2 | import 'package:azure_devops/src/theme/theme.dart';
3 | import 'package:purple_theme/purple_theme.dart';
4 |
5 | /// Threshold that must be exceeded to show projects search field
6 | const int projectsCountThreshold = 10;
7 |
8 | void rebuildApp() {
9 | PurpleTheme.of(AppRouter.rootNavigator!.context).changeTheme(AppTheme.themeMode);
10 | }
11 |
--------------------------------------------------------------------------------
/lib/src/widgets/shortcut_label.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:flutter/material.dart';
3 |
4 | class ShortcutLabel extends StatelessWidget {
5 | const ShortcutLabel({required this.label});
6 |
7 | final String label;
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return Text(label, style: context.textTheme.bodyMedium, textAlign: TextAlign.center);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable/launch_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-v21/launch_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
13 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/src/screens/boards/controller_boards.dart:
--------------------------------------------------------------------------------
1 | part of boards;
2 |
3 | class _BoardsController {
4 | _BoardsController._(this.storage);
5 |
6 | final StorageService storage;
7 |
8 | final allProjects = ValueNotifier?>?>(null);
9 |
10 | Future init() async {
11 | final allProjectsRes = storage.getChosenProjects().toList();
12 | allProjects.value = ApiResponse.ok(allProjectsRes);
13 | }
14 |
15 | void goToProjectBoards(Project project) {
16 | AppRouter.goToProjectBoards(projectId: project.name!);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/lib/src/bindings/fix_ipad_popup_autoclose_binding.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/widgets.dart';
2 |
3 | /// A Flutter binding that fixes the iPad popup menus auto-closing immediately issue.
4 | /// See https://github.com/flutter/flutter/issues/175606 and https://github.com/flutter/flutter/issues/177992
5 | class FixIpadPopupAutocloseFlutterBinding extends WidgetsFlutterBinding {
6 | @override
7 | void handlePointerEvent(PointerEvent event) {
8 | if (event.position == Offset.zero) {
9 | return;
10 | }
11 | super.handlePointerEvent(event);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/.github/workflows/analyze-and-test.yml:
--------------------------------------------------------------------------------
1 | name: Analyze code and run tests
2 | on:
3 | push:
4 | branches: [main]
5 | pull_request:
6 | branches: [main]
7 | jobs:
8 | build:
9 | name: Analyze, format and test
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: subosito/flutter-action@v2
14 | with:
15 | flutter-version: '3.27.1'
16 | channel: 'stable'
17 | - run: flutter pub get
18 | - run: flutter analyze
19 | - run: dart format --set-exit-if-changed -l 120 .
20 | - run: flutter test
21 |
--------------------------------------------------------------------------------
/lib/src/mixins/share_mixin.dart:
--------------------------------------------------------------------------------
1 | import 'dart:ui';
2 |
3 | import 'package:flutter/material.dart';
4 | import 'package:share_plus/share_plus.dart';
5 |
6 | mixin ShareMixin {
7 | void shareUrl(String url) {
8 | final size = MediaQueryData.fromView(PlatformDispatcher.instance.views.first).size;
9 | SharePlus.instance.share(
10 | ShareParams(
11 | uri: Uri.parse(url),
12 | sharePositionOrigin: Rect.fromCenter(
13 | center: Offset(size.width / 2, size.height / 2),
14 | width: size.width / 2,
15 | height: size.height / 2,
16 | ),
17 | ),
18 | );
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/lib/src/models/team_settings.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:http/http.dart';
4 |
5 | class TeamSettingsResponse {
6 | TeamSettingsResponse({required this.backlogVisibilities});
7 |
8 | factory TeamSettingsResponse.fromJson(Map json) => TeamSettingsResponse(
9 | backlogVisibilities: Map.from(json['backlogVisibilities'] as Map? ?? {}),
10 | );
11 |
12 | static TeamSettingsResponse fromResponse(Response res) =>
13 | TeamSettingsResponse.fromJson(jsonDecode(res.body) as Map);
14 |
15 | final Map backlogVisibilities;
16 | }
17 |
--------------------------------------------------------------------------------
/lib/src/screens/splash/screen_splash.dart:
--------------------------------------------------------------------------------
1 | part of splash;
2 |
3 | class _SplashScreen extends StatelessWidget {
4 | const _SplashScreen(this.ctrl, this.parameters);
5 |
6 | final _SplashController ctrl;
7 | final _SplashParameters parameters;
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return Scaffold(
12 | body: ColoredBox(
13 | color: context.themeExtension.background,
14 | child: AppPage.empty(
15 | init: ctrl.init,
16 | builder: (_) => Center(child: Image.asset('assets/logos/logo.png', height: 250)),
17 | ),
18 | ),
19 | );
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/lib/src/extensions/commit_extension.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/models/commit.dart';
2 |
3 | extension CommitExt on Commit {
4 | String get projectName =>
5 | remoteUrl == null ? '-' : remoteUrl!.substring(0, remoteUrl!.indexOf('/_git/')).split('/').last;
6 |
7 | String get repositoryName =>
8 | remoteUrl == null ? '-' : remoteUrl!.substring(0, remoteUrl!.indexOf('/commit/')).split('/').last;
9 |
10 | String get projectId => url == null ? '-' : url!.substring(0, url!.indexOf('/_apis/')).split('/').last;
11 |
12 | String get repositoryId => url == null ? '-' : url!.substring(0, url!.indexOf('/commits/')).split('/').last;
13 | }
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://dart.dev/guides/libraries/private-files
2 |
3 | .dart_tool/
4 | .packages
5 | build/
6 | pubspec.lock # Except for application packages
7 |
8 | doc/api/
9 |
10 | # IntelliJ
11 | *.iml
12 | *.ipr
13 | *.iws
14 | .idea/
15 |
16 | # Mac
17 | .DS_Store
18 |
19 | openapi/
20 | doc/
21 |
22 | .vscode/
23 | .flutter-plugins-dependencies
24 | .flutter-plugins
25 |
26 | .testflight.sh
27 | .googleplay.sh
28 | deploy.dart
29 | android/app/google-services-private.json
30 | lib/firebase_options_private.dart
31 | dart-define-debug.json
32 | dart-define-production.json
33 | dart-define-production-local.json
34 | ios/Flutter/Environment*.xcconfig
35 | ios/prebuild.log
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/src/extensions/string_extension.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/datetime_extension.dart';
2 | import 'package:azure_devops/src/extensions/num_extension.dart';
3 |
4 | extension StringExt on String {
5 | String get formatted {
6 | final date = DateTime.tryParse(this);
7 | final number = num.tryParse(this);
8 |
9 | if (date != null) return date.toDate();
10 |
11 | // check that number doesn't end with '.' to allow inputting a double
12 | if (number != null && !endsWith('.')) return number.formatted;
13 |
14 | return this;
15 | }
16 |
17 | String get titleCase {
18 | if (isEmpty) return this;
19 |
20 | return '${substring(0, 1).toUpperCase()}${substring(1)}';
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/lib/src/screens/boards/screen_boards.dart:
--------------------------------------------------------------------------------
1 | part of boards;
2 |
3 | class _BoardsScreen extends StatelessWidget {
4 | const _BoardsScreen(this.ctrl, this.parameters);
5 |
6 | final _BoardsController ctrl;
7 | final _BoardsParameters parameters;
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return AppPage?>(
12 | init: ctrl.init,
13 | title: 'Boards',
14 | notifier: ctrl.allProjects,
15 | showScrollbar: true,
16 | builder: (projects) => Column(
17 | children: projects!
18 | .map((p) => ProjectCard(height: parameters.projectCardHeight, project: p, onTap: ctrl.goToProjectBoards))
19 | .toList(),
20 | ),
21 | );
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/ios/dart_define_build_script.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | exec > "${SRCROOT}/prebuild.log" 2>&1
4 |
5 | echo "start pre action script"
6 | echo "SRCROOT: ${SRCROOT}"
7 | echo "CONFIGURATION: ${CONFIGURATION}"
8 |
9 | function entry_decode() { echo "${*}" | base64 --decode; }
10 |
11 | IFS=',' read -r -a define_items <<< "$DART_DEFINES"
12 |
13 | for index in "${!define_items[@]}"
14 | do
15 | decodedEntry=$(entry_decode "${define_items[$index]}");
16 |
17 | if [[ $decodedEntry != *"FLUTTER_"* ]];
18 | then define_items["$index"]=$decodedEntry;
19 | else
20 | define_items["$index"]="";
21 | fi
22 |
23 | done
24 |
25 | printf "%s\n" "${define_items[@]}" > "${SRCROOT}/Flutter/Environment${CONFIGURATION}.xcconfig"
26 |
--------------------------------------------------------------------------------
/lib/src/widgets/work_item_type_icon.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/models/processes.dart';
2 | import 'package:flutter/material.dart';
3 | import 'package:flutter_svg/svg.dart';
4 |
5 | class WorkItemTypeIcon extends StatelessWidget {
6 | const WorkItemTypeIcon({this.type, this.size = 20});
7 |
8 | final WorkItemType? type;
9 | final double size;
10 |
11 | @override
12 | Widget build(BuildContext context) {
13 | if (type == null || type == WorkItemType.all) return const SizedBox();
14 |
15 | final colorQuery = type!.color != null ? 'color=${type!.color}&' : '';
16 | final url = 'https://tfsprodweu2.visualstudio.com/_apis/wit/workItemIcons/${type!.icon}?${colorQuery}v=2';
17 | return SvgPicture.network(url, width: size);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/lib/src/widgets/text_title_description.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:flutter/material.dart';
3 |
4 | class TextTitleDescription extends StatelessWidget {
5 | const TextTitleDescription({required this.title, required this.description});
6 |
7 | final String title;
8 | final String description;
9 |
10 | @override
11 | Widget build(BuildContext context) {
12 | return SelectableText.rich(
13 | TextSpan(
14 | children: [
15 | TextSpan(
16 | text: title,
17 | style: context.textTheme.titleSmall!.copyWith(color: context.colorScheme.onSecondary),
18 | ),
19 | TextSpan(text: ' $description', style: context.textTheme.titleSmall),
20 | ],
21 | ),
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/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 | 13.0
25 |
26 |
27 |
--------------------------------------------------------------------------------
/lib/src/models/backlog.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:http/src/response.dart';
4 |
5 | class BacklogsResponse {
6 | BacklogsResponse({required this.boards});
7 |
8 | factory BacklogsResponse.fromJson(Map json) => BacklogsResponse(
9 | boards: List.from(
10 | (json['value'] as List? ?? []).map((x) => Backlog.fromJson(x as Map)),
11 | ),
12 | );
13 |
14 | final List boards;
15 |
16 | static List fromResponse(Response res) =>
17 | BacklogsResponse.fromJson(jsonDecode(res.body) as Map).boards;
18 | }
19 |
20 | class Backlog {
21 | Backlog({required this.id, required this.name});
22 |
23 | factory Backlog.fromJson(Map json) =>
24 | Backlog(id: json['id'] as String? ?? '', name: json['name'] as String? ?? '');
25 |
26 | final String id;
27 | final String name;
28 | }
29 |
--------------------------------------------------------------------------------
/lib/src/screens/file_diff/screen_file_diff.dart:
--------------------------------------------------------------------------------
1 | part of file_diff;
2 |
3 | class _FileDiffScreen extends StatelessWidget {
4 | const _FileDiffScreen(this.ctrl, this.parameters);
5 |
6 | final _FileDiffController ctrl;
7 | final _FileDiffParameters parameters;
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return AppPage(
12 | init: ctrl.init,
13 | title: 'File diff',
14 | actions: [IconButton(onPressed: ctrl.shareDiff, icon: Icon(DevOpsIcons.share))],
15 | notifier: ctrl.diff,
16 | padding: EdgeInsets.zero,
17 | showScrollbar: true,
18 | builder: (diff) => switch (diff) {
19 | final Diff d when d.imageComparison && ctrl.isImageDiff => _ImageDiff(ctrl: ctrl),
20 | final Diff d when d.binaryContent => const Center(child: Text('Cannot show binary file diff')),
21 | _ => _FileDiff(ctrl: ctrl, diff: diff!),
22 | },
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/android/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | def flutterSdkPath = {
3 | def properties = new Properties()
4 | file("local.properties").withInputStream { properties.load(it) }
5 | def flutterSdkPath = properties.getProperty("flutter.sdk")
6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
7 | return flutterSdkPath
8 | }
9 | settings.ext.flutterSdkPath = flutterSdkPath()
10 |
11 | includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
12 |
13 | repositories {
14 | google()
15 | mavenCentral()
16 | gradlePluginPortal()
17 | }
18 | }
19 |
20 | plugins {
21 | id "dev.flutter.flutter-plugin-loader" version "1.0.0"
22 | id "com.android.application" version "8.9.1" apply false
23 | id "org.jetbrains.kotlin.android" version "2.1.21" apply false
24 | id "com.google.gms.google-services" version "4.3.15" apply false
25 | }
26 |
27 | include ":app"
--------------------------------------------------------------------------------
/lib/src/extensions/area_or_iteration_extension.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/models/areas_and_iterations.dart';
2 |
3 | extension AreaOrIterationExt on AreaOrIteration {
4 | String get escapedAreaPath => _getReplaced('Area');
5 |
6 | String get escapedIterationPath => _getReplaced('Iteration');
7 |
8 | String _getReplaced(String str) {
9 | final startsWithBackslash = path.startsWith(r'\');
10 | final res = startsWithBackslash ? path.substring(1) : path;
11 | return res.endsWith('\\$str') ? res.replaceFirst('\\$str', '') : res.replaceAll('\\$str\\', r'\');
12 | }
13 |
14 | String get projectName {
15 | final startsWithBackslash = path.startsWith(r'\');
16 | final res = startsWithBackslash ? path.substring(1) : path;
17 | return res.split(r'\').first;
18 | }
19 |
20 | bool get isActive {
21 | final now = DateTime.now();
22 | return attributes != null && now.isAfter(attributes!.startDate) && now.isBefore(attributes!.finishDate);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/lib/src/models/work_item_type_with_transitions.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:http/http.dart';
4 |
5 | class WorkItemTypeWithTransitions {
6 | WorkItemTypeWithTransitions({required this.xmlForm, required this.referenceName, required this.transitions});
7 |
8 | factory WorkItemTypeWithTransitions.fromResponse(Response res) =>
9 | WorkItemTypeWithTransitions.fromJson(jsonDecode(res.body) as Map);
10 |
11 | factory WorkItemTypeWithTransitions.fromJson(Map json) => WorkItemTypeWithTransitions(
12 | xmlForm: json['xmlForm'] as String? ?? '',
13 | referenceName: json['referenceName'] as String? ?? '',
14 | transitions: (json['transitions'] as Map).map(
15 | (from, tos) => MapEntry(from, (tos as List).map((e) => e['to'].toString()).toList()),
16 | ),
17 | );
18 |
19 | final String xmlForm;
20 | final String referenceName;
21 | final Map> transitions;
22 | }
23 |
--------------------------------------------------------------------------------
/lib/src/widgets/markdown_widget.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 | import 'package:flutter_markdown/flutter_markdown.dart';
3 |
4 | class AppMarkdownWidget extends StatelessWidget {
5 | const AppMarkdownWidget({
6 | required this.data,
7 | this.styleSheet,
8 | this.onTapLink,
9 | this.shrinkWrap = true,
10 | this.paddingBuilders = const {},
11 | });
12 |
13 | final String data;
14 | final MarkdownStyleSheet? styleSheet;
15 | final void Function(String, String?, String)? onTapLink;
16 | final bool shrinkWrap;
17 | final Map paddingBuilders;
18 |
19 | @override
20 | Widget build(BuildContext context) {
21 | return SelectionArea(
22 | child: MarkdownBody(
23 | data: data,
24 | styleSheet: styleSheet,
25 | onTapLink: onTapLink,
26 | shrinkWrap: shrinkWrap,
27 | paddingBuilders: paddingBuilders,
28 | ),
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/lib/src/models/work_item_tags.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:http/http.dart';
4 |
5 | class WorkItemTagsResponse {
6 | WorkItemTagsResponse({required this.tags});
7 |
8 | factory WorkItemTagsResponse.fromResponse(Response res) =>
9 | WorkItemTagsResponse.fromJson(json.decode(res.body) as Map);
10 |
11 | factory WorkItemTagsResponse.fromJson(Map json) => WorkItemTagsResponse(
12 | tags: List.from(
13 | (json['value'] as List).map((x) => WorkItemTag.fromJson(x as Map)),
14 | ),
15 | );
16 |
17 | final List tags;
18 | }
19 |
20 | class WorkItemTag {
21 | WorkItemTag({required this.name});
22 |
23 | factory WorkItemTag.fromResponse(Response res) => WorkItemTag.fromJson(json.decode(res.body) as Map);
24 |
25 | factory WorkItemTag.fromJson(Map json) => WorkItemTag(name: json['name'] as String);
26 |
27 | final String name;
28 | }
29 |
--------------------------------------------------------------------------------
/lib/src/services/share_intent_service.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 |
3 | import 'package:azure_devops/src/mixins/logger_mixin.dart';
4 | import 'package:azure_devops/src/router/share_extension_router.dart';
5 | import 'package:flutter/services.dart';
6 |
7 | /// Handles URLs shared from Android Sharesheet.
8 | class ShareIntentService with AppLogger {
9 | factory ShareIntentService() {
10 | return instance ??= ShareIntentService._();
11 | }
12 |
13 | ShareIntentService._() {
14 | setTag('ShareIntentService');
15 | }
16 |
17 | static ShareIntentService? instance;
18 |
19 | static const _shareExtensionChannel = MethodChannel('io.purplesoft.azuredevops.shareextension');
20 |
21 | Future maybeHandleSharedUrl() async {
22 | final url = (await _shareExtensionChannel.invokeMethod('getSharedUrl')) as String? ?? '';
23 | logDebug('shared url: $url');
24 | if (url.isEmpty) return;
25 |
26 | unawaited(ShareExtensionRouter.handleRoute(Uri.parse(url)));
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/test/project_detail_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/screens/project_detail/base_project_detail.dart';
2 | import 'package:azure_devops/src/services/azure_api_service.dart';
3 | import 'package:flutter/material.dart';
4 | import 'package:flutter_test/flutter_test.dart';
5 |
6 | import 'api_service_mock.dart';
7 |
8 | /// Mock details taken from [AzureApiServiceMock.getProjectTeams]
9 | void main() {
10 | TestWidgetsFlutterBinding.ensureInitialized();
11 |
12 | testWidgets('Page building test', (t) async {
13 | final app = AzureApiServiceWidget(
14 | api: AzureApiServiceMock(),
15 | child: MaterialApp(
16 | theme: mockTheme,
17 | onGenerateRoute: (_) => MaterialPageRoute(
18 | builder: (_) => ProjectDetailPage(),
19 | settings: RouteSettings(arguments: 'test name'),
20 | ),
21 | ),
22 | );
23 |
24 | await t.pumpWidget(app);
25 | await t.pumpAndSettle();
26 |
27 | expect(find.byType(ProjectDetailPage), findsOneWidget);
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/lib/src/screens/project_detail/components_project_detail.dart:
--------------------------------------------------------------------------------
1 | part of project_detail;
2 |
3 | class _StatsChip extends StatelessWidget {
4 | const _StatsChip({required this.name, required this.value});
5 |
6 | final String name;
7 | final String value;
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return Padding(
12 | padding: const EdgeInsets.only(right: 10),
13 | child: Chip(
14 | visualDensity: VisualDensity.adaptivePlatformDensity,
15 | label: Text.rich(
16 | TextSpan(
17 | children: [
18 | TextSpan(
19 | text: '$name ',
20 | style: context.textTheme.labelMedium!.copyWith(color: context.themeExtension.onBackground),
21 | ),
22 | TextSpan(
23 | text: value,
24 | style: context.textTheme.labelMedium!.copyWith(color: Colors.green),
25 | ),
26 | ],
27 | ),
28 | ),
29 | ),
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/test/member_detail_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/screens/member_detail/base_member_detail.dart';
2 | import 'package:azure_devops/src/services/azure_api_service.dart';
3 | import 'package:flutter/material.dart';
4 | import 'package:flutter_test/flutter_test.dart';
5 |
6 | import 'api_service_mock.dart';
7 |
8 | /// Mock member is taken from [AzureApiServiceMock.getUserFromDescriptor]
9 | void main() {
10 | TestWidgetsFlutterBinding.ensureInitialized();
11 |
12 | testWidgets('Page building test', (t) async {
13 | final memberPage = AzureApiServiceWidget(
14 | api: AzureApiServiceMock(),
15 | child: MaterialApp(
16 | theme: mockTheme,
17 | onGenerateRoute: (_) => MaterialPageRoute(
18 | builder: (_) => MemberDetailPage(),
19 | settings: RouteSettings(arguments: ''),
20 | ),
21 | ),
22 | );
23 |
24 | await t.pumpWidget(memberPage);
25 | await t.pumpAndSettle();
26 |
27 | expect(find.byType(MemberDetailPage), findsOneWidget);
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/.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.
5 |
6 | version:
7 | revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
8 | channel: stable
9 |
10 | project_type: app
11 |
12 | # Tracks metadata for the flutter migrate command
13 | migration:
14 | platforms:
15 | - platform: root
16 | create_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
17 | base_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
18 | - platform: ios
19 | create_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
20 | base_revision: 4d9e56e694b656610ab87fcf2efbcd226e0ed8cf
21 |
22 | # User provided section
23 |
24 | # List of Local paths (relative to this file) that should be
25 | # ignored by the migrate tool.
26 | #
27 | # Files that are not part of the templates will be ignored by default.
28 | unmanaged_files:
29 | - 'lib/main.dart'
30 | - 'ios/Runner.xcodeproj/project.pbxproj'
31 |
--------------------------------------------------------------------------------
/lib/src/widgets/empty_page.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/widgets/app_page.dart';
2 | import 'package:azure_devops/src/widgets/loading_button.dart';
3 | import 'package:flutter/material.dart';
4 |
5 | class EmptyPage extends StatelessWidget {
6 | const EmptyPage({required this.widget, required this.onRefresh});
7 |
8 | final AppPage widget;
9 | final VoidCallback onRefresh;
10 |
11 | @override
12 | Widget build(BuildContext context) {
13 | return Column(
14 | mainAxisAlignment: MainAxisAlignment.center,
15 | children: [
16 | Image.asset('assets/illustrations/empty.png', height: 200),
17 | const SizedBox(height: 20),
18 | if (widget.onEmpty != null) Text(widget.onEmpty!),
19 | const SizedBox(height: 20),
20 | if (widget.onResetFilters != null)
21 | LoadingButton(onPressed: widget.onResetFilters!, text: 'Reset filters')
22 | else
23 | LoadingButton(onPressed: onRefresh, text: 'Retry'),
24 | const SizedBox(height: 40),
25 | ],
26 | );
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/lib/src/models/shared.dart:
--------------------------------------------------------------------------------
1 | class Links {
2 | Links({
3 | required this.self,
4 | required this.memberships,
5 | required this.membershipState,
6 | required this.storageKey,
7 | required this.avatar,
8 | });
9 |
10 | factory Links.fromJson(Map json) => Links(
11 | self: Avatar.fromJson(json['self'] as Map),
12 | memberships: Avatar.fromJson(json['memberships'] as Map),
13 | membershipState: Avatar.fromJson(json['membershipState'] as Map),
14 | storageKey: Avatar.fromJson(json['storageKey'] as Map),
15 | avatar: Avatar.fromJson(json['avatar'] as Map),
16 | );
17 |
18 | final Avatar? self;
19 | final Avatar? memberships;
20 | final Avatar? membershipState;
21 | final Avatar? storageKey;
22 | final Avatar? avatar;
23 | }
24 |
25 | class Avatar {
26 | Avatar({required this.href});
27 |
28 | factory Avatar.fromJson(Map json) => Avatar(href: json['href'] as String?);
29 |
30 | final String? href;
31 | }
32 |
--------------------------------------------------------------------------------
/test/commit_detail_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/screens/commit_detail/base_commit_detail.dart';
2 | import 'package:azure_devops/src/services/azure_api_service.dart';
3 | import 'package:flutter/material.dart';
4 | import 'package:flutter_test/flutter_test.dart';
5 |
6 | import 'api_service_mock.dart';
7 |
8 | /// Mock commit is taken from [AzureApiServiceMock.getCommitDetail]
9 | void main() {
10 | TestWidgetsFlutterBinding.ensureInitialized();
11 |
12 | testWidgets('Page building test', (t) async {
13 | final app = AzureApiServiceWidget(
14 | api: AzureApiServiceMock(),
15 | child: MaterialApp(
16 | theme: mockTheme,
17 | onGenerateRoute: (_) => MaterialPageRoute(
18 | builder: (_) => CommitDetailPage(),
19 | settings: RouteSettings(arguments: (commitId: '123456789', project: 'TestProject', repository: 'test_repo')),
20 | ),
21 | ),
22 | );
23 |
24 | await t.pumpWidget(app);
25 | await t.pump();
26 |
27 | expect(find.byType(CommitDetailPage), findsOneWidget);
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/lib/src/extensions/context_extension.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/services/ads_service.dart';
2 | import 'package:azure_devops/src/services/azure_api_service.dart';
3 | import 'package:azure_devops/src/services/purchase_service.dart';
4 | import 'package:azure_devops/src/services/storage_service.dart';
5 | import 'package:azure_devops/src/theme/theme.dart';
6 | import 'package:flutter/material.dart';
7 |
8 | extension PurpleContext on BuildContext {
9 | TextTheme get textTheme => Theme.of(this).textTheme;
10 | ColorScheme get colorScheme => Theme.of(this).colorScheme;
11 | AppColorsExtension get themeExtension => Theme.of(this).extension()!;
12 | double get height => MediaQuery.of(this).size.height;
13 | double get width => MediaQuery.of(this).size.width;
14 |
15 | AzureApiService get api => AzureApiServiceWidget.of(this).api;
16 | PurchaseService get purchase => PurchaseServiceWidget.of(this).purchase;
17 | AdsService get ads => AdsServiceWidget.of(this).ads;
18 | StorageService get storage => StorageServiceWidget.of(this).storage;
19 | }
20 |
--------------------------------------------------------------------------------
/lib/src/models/team_areas.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:http/http.dart';
4 |
5 | class TeamAreasResponse {
6 | TeamAreasResponse({required this.defaultValue, required this.values});
7 |
8 | factory TeamAreasResponse.fromJson(Map json) => TeamAreasResponse(
9 | defaultValue: json['defaultValue'] as String? ?? '',
10 | values: List<_TeamArea>.from(
11 | (json['values'] as List? ?? []).map((x) => _TeamArea.fromJson(x as Map? ?? {})),
12 | ),
13 | );
14 |
15 | static TeamAreasResponse fromResponse(Response res) =>
16 | TeamAreasResponse.fromJson(jsonDecode(res.body) as Map);
17 |
18 | final String defaultValue;
19 | final List<_TeamArea> values;
20 | }
21 |
22 | class _TeamArea {
23 | _TeamArea({required this.value, required this.includeChildren});
24 |
25 | factory _TeamArea.fromJson(Map json) =>
26 | _TeamArea(value: json['value'] as String? ?? '', includeChildren: json['includeChildren'] as bool? ?? false);
27 |
28 | final String value;
29 | final bool includeChildren;
30 | }
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 PurpleSoft S.r.l.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
16 |
19 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values-night/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
16 |
19 |
--------------------------------------------------------------------------------
/test/repository_detail_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/router/router.dart';
2 | import 'package:azure_devops/src/screens/repository_detail/base_repository_detail.dart';
3 | import 'package:azure_devops/src/services/azure_api_service.dart';
4 | import 'package:flutter/material.dart';
5 | import 'package:flutter_test/flutter_test.dart';
6 | import 'api_service_mock.dart';
7 |
8 | /// Mock repository items are taken from [AzureApiServiceMock.getRepositoryItems]
9 | void main() {
10 | TestWidgetsFlutterBinding.ensureInitialized();
11 |
12 | testWidgets('Page building test', (t) async {
13 | final app = AzureApiServiceWidget(
14 | api: AzureApiServiceMock(),
15 | child: MaterialApp(
16 | theme: mockTheme,
17 | onGenerateRoute: (_) => MaterialPageRoute(
18 | builder: (_) => RepositoryDetailPage(),
19 | settings: RouteSettings(
20 | arguments: RepoDetailArgs(projectName: '', repositoryName: ''),
21 | ),
22 | ),
23 | ),
24 | );
25 |
26 | await t.pumpWidget(app);
27 | await t.pump();
28 |
29 | expect(find.byType(RepositoryDetailPage), findsOneWidget);
30 | });
31 | }
32 |
--------------------------------------------------------------------------------
/lib/src/widgets/error_page.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:azure_devops/src/widgets/loading_button.dart';
3 | import 'package:flutter/material.dart';
4 |
5 | class ErrorPage extends StatelessWidget {
6 | const ErrorPage({super.key, required this.description, required this.onRetry});
7 |
8 | final String description;
9 | final VoidCallback onRetry;
10 |
11 | @override
12 | Widget build(BuildContext context) {
13 | return Material(
14 | child: ColoredBox(
15 | color: context.themeExtension.background,
16 | child: Column(
17 | mainAxisAlignment: MainAxisAlignment.center,
18 | children: [
19 | Image.asset('assets/illustrations/error.png', width: 150),
20 | const SizedBox(height: 40),
21 | Text('Error', style: context.textTheme.headlineLarge),
22 | const SizedBox(height: 10),
23 | Text(description, textAlign: TextAlign.center),
24 | const SizedBox(height: 20),
25 | LoadingButton(onPressed: onRetry, text: 'Tap to retry'),
26 | ],
27 | ),
28 | ),
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/lib/src/widgets/work_card.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:azure_devops/src/widgets/navigation_button.dart';
3 | import 'package:flutter/material.dart';
4 |
5 | class WorkCard extends StatelessWidget {
6 | const WorkCard({required this.title, required this.onTap, required this.icon, required this.index});
7 |
8 | final String title;
9 | final VoidCallback onTap;
10 | final IconData icon;
11 | final int index;
12 |
13 | @override
14 | Widget build(BuildContext context) {
15 | return NavigationButton(
16 | inkwellKey: ValueKey(title),
17 | onTap: onTap,
18 | child: Column(
19 | mainAxisAlignment: MainAxisAlignment.center,
20 | children: [
21 | DecoratedBox(
22 | decoration: BoxDecoration(color: context.colorScheme.primary, shape: BoxShape.circle),
23 | child: Padding(
24 | padding: const EdgeInsets.all(10),
25 | child: Icon(icon, color: context.colorScheme.onPrimary),
26 | ),
27 | ),
28 | const SizedBox(height: 16),
29 | Text(title, style: context.textTheme.bodyLarge),
30 | ],
31 | ),
32 | );
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/ios/AzDevopsShareExt/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSExtension
6 |
7 | NSExtensionAttributes
8 |
9 | NSExtensionActivationRule
10 |
11 | NSExtensionActivationSupportsAttachmentsWithMaxCount
12 | 0
13 | NSExtensionActivationSupportsFileWithMaxCount
14 | 0
15 | NSExtensionActivationSupportsImageWithMaxCount
16 | 0
17 | NSExtensionActivationSupportsMovieWithMaxCount
18 | 0
19 | NSExtensionActivationSupportsText
20 |
21 | NSExtensionActivationSupportsWebPageWithMaxCount
22 | 1
23 | NSExtensionActivationSupportsWebURLWithMaxCount
24 | 1
25 |
26 |
27 | NSExtensionMainStoryboard
28 | MainInterface
29 | NSExtensionPointIdentifier
30 | com.apple.share-services
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/lib/src/screens/sprint_detail/screen_sprint_detail.dart:
--------------------------------------------------------------------------------
1 | part of sprint_detail;
2 |
3 | class _SprintDetailScreen extends StatelessWidget {
4 | const _SprintDetailScreen(this.ctrl, this.parameters);
5 |
6 | final _SprintDetailController ctrl;
7 | final _SprintDetailParameters parameters;
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return LayoutBuilder(
12 | builder: (context, constraints) => AppPage(
13 | init: ctrl.init,
14 | title: ctrl.args.sprintName,
15 | notifier: ctrl.sprintWithItems,
16 | padding: EdgeInsets.zero,
17 | actions: [_Actions(ctrl: ctrl)],
18 | header: () => _Filters(ctrl: ctrl),
19 | builder: (_) => DefaultTabController(
20 | length: ctrl.columnItems.length,
21 | child: Builder(
22 | builder: (ctx) => BoardWidget(
23 | maxHeight: constraints.maxHeight,
24 | tabController: DefaultTabController.of(ctx),
25 | columnItems: ctrl.columnItems,
26 | onTapItem: ctrl.goToDetail,
27 | actions: (item) => [PopupItem(text: 'Edit', onTap: () => ctrl.editItem(item))],
28 | ),
29 | ),
30 | ),
31 | ),
32 | );
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/lib/src/screens/member_detail/controller_member_detail.dart:
--------------------------------------------------------------------------------
1 | part of member_detail;
2 |
3 | class _MemberDetailController {
4 | _MemberDetailController._(this.userDescriptor, this.api);
5 |
6 | final String userDescriptor;
7 |
8 | final AzureApiService api;
9 |
10 | final recentCommits = ValueNotifier?>(null);
11 |
12 | final user = ValueNotifier?>(null);
13 |
14 | Future init() async {
15 | final userRes = await api.getUserFromDescriptor(descriptor: userDescriptor);
16 |
17 | if (userRes.isError || userRes.data == null) {
18 | recentCommits.value = [];
19 | user.value = userRes;
20 | return;
21 | }
22 |
23 | user.value = userRes;
24 |
25 | final res = await api.getRecentCommits(authors: {user.value!.data!.mailAddress ?? ''}, maxCount: 20);
26 | res.data?.sort((a, b) => b.author!.date!.compareTo(a.author!.date!));
27 |
28 | final commits = res.data?.take(10);
29 |
30 | recentCommits.value = commits?.toList();
31 | }
32 |
33 | void goToCommitDetail(Commit commit) {
34 | AppRouter.goToCommitDetail(
35 | project: commit.projectName,
36 | repository: commit.repositoryName,
37 | commitId: commit.commitId!,
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/test/pipeline_logs_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/screens/pipeline_logs/base_pipeline_logs.dart';
2 | import 'package:azure_devops/src/services/azure_api_service.dart';
3 | import 'package:flutter/material.dart';
4 | import 'package:flutter_test/flutter_test.dart';
5 |
6 | import 'api_service_mock.dart';
7 |
8 | /// Mock log is taken from [AzureApiServiceMock.getPipelineTaskLogs]
9 | void main() {
10 | TestWidgetsFlutterBinding.ensureInitialized();
11 |
12 | testWidgets('Page building test', (t) async {
13 | final pipelineLogsPage = AzureApiServiceWidget(
14 | api: AzureApiServiceMock(),
15 | child: MaterialApp(
16 | theme: mockTheme,
17 | onGenerateRoute: (_) => MaterialPageRoute(
18 | builder: (_) => PipelineLogsPage(),
19 | settings: RouteSettings(
20 | arguments: (
21 | logId: 1,
22 | parentTaskId: 'parent task id test',
23 | pipelineId: 1,
24 | project: 'project test',
25 | taskId: 'task id test',
26 | ),
27 | ),
28 | ),
29 | ),
30 | );
31 |
32 | await t.pumpWidget(pipelineLogsPage);
33 | await t.pump();
34 |
35 | expect(find.byType(PipelineLogsPage), findsOneWidget);
36 | });
37 | }
38 |
--------------------------------------------------------------------------------
/test/file_detail_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/router/router.dart';
2 | import 'package:azure_devops/src/screens/file_detail/base_file_detail.dart';
3 | import 'package:azure_devops/src/services/azure_api_service.dart';
4 | import 'package:flutter/material.dart';
5 | import 'package:flutter_test/flutter_test.dart';
6 | import 'api_service_mock.dart';
7 |
8 | /// Mock file is taken from [AzureApiServiceMock.getFileDetail]
9 | void main() {
10 | TestWidgetsFlutterBinding.ensureInitialized();
11 |
12 | testWidgets('Page building test', (t) async {
13 | final app = AzureApiServiceWidget(
14 | api: AzureApiServiceMock(),
15 | child: MaterialApp(
16 | theme: mockTheme,
17 | onGenerateRoute: (_) => MaterialPageRoute(
18 | builder: (_) => FileDetailPage(),
19 | settings: RouteSettings(
20 | arguments: RepoDetailArgs(
21 | projectName: 'project name',
22 | repositoryName: 'repo name',
23 | filePath: 'path',
24 | branch: 'branch test',
25 | ),
26 | ),
27 | ),
28 | ),
29 | );
30 |
31 | await t.pumpWidget(app);
32 | await t.pumpAndSettle();
33 |
34 | expect(find.byType(FileDetailPage), findsOneWidget);
35 | });
36 | }
37 |
--------------------------------------------------------------------------------
/lib/src/widgets/navigation_button.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:azure_devops/src/theme/theme.dart';
3 | import 'package:flutter/material.dart';
4 |
5 | class NavigationButton extends StatelessWidget {
6 | const NavigationButton({
7 | required this.child,
8 | this.onTap,
9 | this.padding = const EdgeInsets.all(15),
10 | this.inkwellKey,
11 | this.backgroundColor,
12 | this.margin,
13 | });
14 |
15 | final Widget child;
16 | final VoidCallback? onTap;
17 | final EdgeInsets padding;
18 | final ValueKey? inkwellKey;
19 | final Color? backgroundColor;
20 | final EdgeInsets? margin;
21 |
22 | @override
23 | Widget build(BuildContext context) {
24 | Widget button = InkWell(
25 | key: inkwellKey,
26 | onTap: onTap,
27 | child: DecoratedBox(
28 | decoration: BoxDecoration(
29 | color: backgroundColor ?? context.colorScheme.surface,
30 | borderRadius: BorderRadius.circular(AppTheme.radius),
31 | ),
32 | child: Padding(padding: padding, child: child),
33 | ),
34 | );
35 |
36 | if (margin != null) {
37 | button = Padding(padding: margin!, child: button);
38 | }
39 |
40 | return button;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/lib/src/extensions/work_item_relation_extension.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/models/work_item_link_types.dart';
2 | import 'package:azure_devops/src/models/work_item_updates.dart';
3 | import 'package:azure_devops/src/models/work_items.dart';
4 |
5 | extension WorkItemRelationExt on Relation {
6 | String toReadableString() {
7 | final id = url?.split('/').lastOrNull ?? '';
8 | final attributes = this.attributes;
9 | final attributesStr = attributes != null ? attributes.name : '';
10 | return '$id - $attributesStr';
11 | }
12 |
13 | String get linkedWorkItemProjectId => url?.substring(0, url?.indexOf('/_apis/')).split('/').lastOrNull ?? '';
14 |
15 | int get linkedWorkItemId => int.tryParse(url?.split('/').lastOrNull ?? '') ?? 0;
16 |
17 | WorkItemLink toWorkItemLink({required int index}) => WorkItemLink(
18 | linkTypeReferenceName: rel ?? '',
19 | linkTypeName: attributes?.name ?? '',
20 | linkedWorkItemId: int.tryParse(url?.split('/').lastOrNull ?? '') ?? 0,
21 | comment: attributes?.comment ?? '',
22 | index: index,
23 | );
24 |
25 | bool get isWorkItemLink => rel?.startsWith('System.LinkTypes.') ?? false;
26 | }
27 |
28 | extension WorkItemExt on WorkItem {
29 | List get workItemLinks => links.where((l) => l.isWorkItemLink).toList();
30 | }
31 |
--------------------------------------------------------------------------------
/lib/src/screens/tabs/base_tabs.dart:
--------------------------------------------------------------------------------
1 | library tabs;
2 |
3 | import 'package:azure_devops/main.dart';
4 | import 'package:azure_devops/src/extensions/context_extension.dart';
5 | import 'package:azure_devops/src/router/router.dart';
6 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
7 | import 'package:azure_devops/src/widgets/app_base_page.dart';
8 | import 'package:azure_devops/src/widgets/app_page.dart';
9 | import 'package:firebase_analytics/firebase_analytics.dart';
10 | import 'package:flutter/cupertino.dart';
11 | import 'package:flutter/material.dart';
12 | import 'package:sentry_flutter/sentry_flutter.dart';
13 |
14 | part 'components_tabs.dart';
15 | part 'controller_tabs.dart';
16 | part 'parameters_tabs.dart';
17 | part 'screen_tabs.dart';
18 |
19 | class TabsPage extends StatelessWidget {
20 | const TabsPage();
21 |
22 | static const _smartphoneParameters = _TabsParameters(tabBarHeight: 50);
23 | static const _tabletParameters = _TabsParameters(tabBarHeight: 80, tabIconHeight: 40);
24 |
25 | @override
26 | Widget build(BuildContext context) {
27 | return AppBasePage(
28 | initState: _TabsController._,
29 | smartphone: (ctrl) => _TabsScreen(ctrl, _smartphoneParameters),
30 | tablet: (ctrl) => _TabsScreen(ctrl, _tabletParameters),
31 | );
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/lib/src/widgets/app_base_page.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/theme/theme.dart';
2 | import 'package:flutter/material.dart';
3 |
4 | class AppBasePage extends StatefulWidget {
5 | const AppBasePage({required this.initState, required this.smartphone, required this.tablet});
6 |
7 | final T Function() initState;
8 | final Widget Function(T) smartphone;
9 | final Widget Function(T) tablet;
10 |
11 | @override
12 | State> createState() => _AppBasePageState();
13 | }
14 |
15 | class _AppBasePageState extends State> {
16 | late T _ctrl;
17 |
18 | @override
19 | void initState() {
20 | super.initState();
21 | _ctrl = widget.initState();
22 | }
23 |
24 | @override
25 | Widget build(BuildContext context) {
26 | return AppLayoutBuilder(smartphone: widget.smartphone(_ctrl), tablet: widget.tablet(_ctrl));
27 | }
28 | }
29 |
30 | class AppLayoutBuilder extends StatelessWidget {
31 | const AppLayoutBuilder({required this.smartphone, required this.tablet});
32 |
33 | final Widget smartphone;
34 | final Widget tablet;
35 |
36 | @override
37 | Widget build(BuildContext context) {
38 | return LayoutBuilder(
39 | builder: (_, constraints) => constraints.maxWidth < AppTheme.tabletBreakpoint ? smartphone : tablet,
40 | );
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/lib/src/extensions/datetime_extension.dart:
--------------------------------------------------------------------------------
1 | import 'package:intl/intl.dart';
2 |
3 | extension PurpleDateTime on DateTime {
4 | String toSimpleDate() => '${DateFormat.yMd().format(this)} ${DateFormat.Hm().format(this)}';
5 |
6 | String toDate() => DateFormat.yMd().format(toLocal());
7 |
8 | String get minutesAgo {
9 | final now = DateTime.now();
10 | final diff = now.difference(this);
11 |
12 | if (diff.inDays > 364) {
13 | return '${diff.inDays ~/ 364}y';
14 | }
15 |
16 | if (diff.inDays > 0) {
17 | return '${diff.inDays}d';
18 | }
19 |
20 | if (diff.inHours > 0) {
21 | return '${diff.inHours}h';
22 | }
23 |
24 | return diff.inMinutes <= 0 ? 'now' : '${diff.inMinutes}m';
25 | }
26 |
27 | bool isToday() {
28 | final now = DateTime.now();
29 | return year == now.year && month == now.month && day == now.day;
30 | }
31 |
32 | String timeDifference(DateTime other) {
33 | final diff = difference(other);
34 |
35 | if (diff.inHours > 0) {
36 | return '${diff.inHours}h ${diff.inMinutes % 60}m';
37 | }
38 |
39 | if (diff.inMinutes > 0) {
40 | return '${diff.inMinutes}m ${diff.inSeconds % 60}s';
41 | }
42 |
43 | if (diff.inSeconds > 0) {
44 | return '${diff.inSeconds}s';
45 | }
46 |
47 | return '-';
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/test/work_items_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/router/router.dart';
2 | import 'package:azure_devops/src/screens/work_items/base_work_items.dart';
3 | import 'package:azure_devops/src/services/ads_service.dart';
4 | import 'package:azure_devops/src/services/azure_api_service.dart';
5 | import 'package:azure_devops/src/services/storage_service.dart';
6 | import 'package:flutter/material.dart';
7 | import 'package:flutter_test/flutter_test.dart';
8 | import 'package:visibility_detector/visibility_detector.dart';
9 |
10 | import 'api_service_mock.dart';
11 |
12 | void main() {
13 | setUp(() => VisibilityDetectorController.instance.updateInterval = Duration.zero);
14 |
15 | TestWidgetsFlutterBinding.ensureInitialized();
16 |
17 | testWidgets('Page building test', (t) async {
18 | final app = MaterialApp(
19 | navigatorKey: AppRouter.navigatorKey,
20 | theme: mockTheme,
21 | home: StorageServiceWidget(
22 | storage: StorageServiceMock(),
23 | child: AdsServiceWidget(
24 | ads: AdsServiceMock(),
25 | child: AzureApiServiceWidget(api: AzureApiServiceMock(), child: WorkItemsPage()),
26 | ),
27 | ),
28 | );
29 |
30 | await t.pumpWidget(app);
31 |
32 | await t.pump();
33 |
34 | expect(find.byType(WorkItemsPage), findsOneWidget);
35 | });
36 | }
37 |
--------------------------------------------------------------------------------
/ios/Runner/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import Flutter
3 | import MSAL
4 |
5 | @main
6 | @objc class AppDelegate: FlutterAppDelegate {
7 | override func application(
8 | _ application: UIApplication,
9 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
10 | ) -> Bool {
11 | GeneratedPluginRegistrant.register(with: self)
12 | return super.application(application, didFinishLaunchingWithOptions: launchOptions)
13 | }
14 |
15 | override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
16 | if (url.absoluteString.hasPrefix("azdevopsshareext.io.purplesoft.azuredevops://share?")) {
17 | let cleanUrlString = url.absoluteString.replacingOccurrences(of: "azdevopsshareext.io.purplesoft.azuredevops://share?", with: "sharedUrl?")
18 | let cleanUrl = URL(string: cleanUrlString)!
19 | print("AppDelegate: Handling URL \(cleanUrl)")
20 | return super.application(app, open: cleanUrl, options:options)
21 | }
22 |
23 | if (url.absoluteString.hasPrefix("msauth.io.purplesoft.azuredevops")) {
24 | return MSALPublicClientApplication.handleMSALResponse(url, sourceApplication: nil)
25 | }
26 |
27 | return false
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/lib/src/screens/boards/base_boards.dart:
--------------------------------------------------------------------------------
1 | library boards;
2 |
3 | import 'package:azure_devops/src/extensions/context_extension.dart';
4 | import 'package:azure_devops/src/models/project.dart';
5 | import 'package:azure_devops/src/router/router.dart';
6 | import 'package:azure_devops/src/services/azure_api_service.dart';
7 | import 'package:azure_devops/src/services/storage_service.dart';
8 | import 'package:azure_devops/src/widgets/app_base_page.dart';
9 | import 'package:azure_devops/src/widgets/app_page.dart';
10 | import 'package:azure_devops/src/widgets/project_card.dart';
11 | import 'package:flutter/material.dart';
12 |
13 | part 'components_boards.dart';
14 | part 'controller_boards.dart';
15 | part 'screen_boards.dart';
16 |
17 | typedef _BoardsParameters = ({double? projectCardHeight});
18 |
19 | class BoardsPage extends StatelessWidget {
20 | const BoardsPage();
21 |
22 | static const _BoardsParameters _smartphoneParameters = (projectCardHeight: null);
23 | static const _BoardsParameters _tabletParameters = (projectCardHeight: 60);
24 |
25 | @override
26 | Widget build(BuildContext context) {
27 | return AppBasePage(
28 | initState: () => _BoardsController._(context.storage),
29 | smartphone: (ctrl) => _BoardsScreen(ctrl, _smartphoneParameters),
30 | tablet: (ctrl) => _BoardsScreen(ctrl, _tabletParameters),
31 | );
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/test/pipeline_detail_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/screens/pipeline_detail/base_pipeline_detail.dart';
2 | import 'package:azure_devops/src/services/ads_service.dart';
3 | import 'package:azure_devops/src/services/azure_api_service.dart';
4 | import 'package:flutter/material.dart';
5 | import 'package:flutter_test/flutter_test.dart';
6 | import 'package:visibility_detector/visibility_detector.dart';
7 |
8 | import 'api_service_mock.dart';
9 |
10 | /// Mock pipeline is taken from [AzureApiServiceMock.getPipeline]
11 | void main() {
12 | setUp(() => VisibilityDetectorController.instance.updateInterval = Duration.zero);
13 |
14 | TestWidgetsFlutterBinding.ensureInitialized();
15 |
16 | testWidgets('Page building test', (t) async {
17 | final app = AdsServiceWidget(
18 | ads: AdsServiceMock(),
19 | child: AzureApiServiceWidget(
20 | api: AzureApiServiceMock(),
21 | child: MaterialApp(
22 | theme: mockTheme,
23 | onGenerateRoute: (_) => MaterialPageRoute(
24 | builder: (_) => PipelineDetailPage(),
25 | settings: RouteSettings(arguments: (id: 1234, project: 'TestProject')),
26 | ),
27 | ),
28 | ),
29 | );
30 |
31 | await t.pumpWidget(app);
32 |
33 | await t.pump();
34 |
35 | expect(find.byType(PipelineDetailPage), findsOneWidget);
36 | });
37 | }
38 |
--------------------------------------------------------------------------------
/lib/src/screens/board_detail/screen_board_detail.dart:
--------------------------------------------------------------------------------
1 | part of board_detail;
2 |
3 | class _BoardDetailScreen extends StatelessWidget {
4 | const _BoardDetailScreen(this.ctrl, this.parameters);
5 |
6 | final _BoardDetailController ctrl;
7 | final _BoardDetailParameters parameters;
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return LayoutBuilder(
12 | builder: (context, constraints) => AppPage(
13 | init: ctrl.init,
14 | title: ctrl.args.boardName,
15 | notifier: ctrl.boardWithItems,
16 | padding: EdgeInsets.zero,
17 | actions: [_Actions(ctrl: ctrl)],
18 | header: () => _Filters(ctrl: ctrl),
19 | builder: (_) => DefaultTabController(
20 | length: ctrl.columnItems.length,
21 | child: Builder(
22 | builder: (ctx) => BoardWidget(
23 | maxHeight: constraints.maxHeight,
24 | columnItems: ctrl.columnItems,
25 | onTapItem: ctrl.goToDetail,
26 | tabController: DefaultTabController.of(ctx),
27 | actions: (item) => [
28 | PopupItem(text: 'Edit', onTap: () => ctrl.editItem(item)),
29 | PopupItem(text: 'Move to column', onTap: () => ctrl.moveToColumn(item)),
30 | ],
31 | ),
32 | ),
33 | ),
34 | ),
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/lib/src/widgets/pipeline_in_progress_animated_icon.dart:
--------------------------------------------------------------------------------
1 | import 'package:flutter/material.dart';
2 |
3 | class InProgressPipelineIcon extends StatefulWidget {
4 | const InProgressPipelineIcon({required this.child});
5 |
6 | final Widget child;
7 |
8 | @override
9 | State createState() => _InProgressPipelineIconState();
10 | }
11 |
12 | class _InProgressPipelineIconState extends State with SingleTickerProviderStateMixin {
13 | late Animation _animation;
14 | late AnimationController _animationController;
15 |
16 | @override
17 | void initState() {
18 | super.initState();
19 | _animationController = AnimationController(vsync: this, duration: Duration(seconds: 4))..forward();
20 | _animation = Tween(begin: 0, end: 1).animate(_animationController);
21 |
22 | _animationController.addListener(() {
23 | if (_animationController.status == AnimationStatus.completed) {
24 | if (!mounted) return;
25 |
26 | setState(() {
27 | _animationController.repeat();
28 | });
29 | }
30 | });
31 | }
32 |
33 | @override
34 | void dispose() {
35 | _animationController.dispose();
36 | super.dispose();
37 | }
38 |
39 | @override
40 | Widget build(BuildContext context) {
41 | return RotationTransition(turns: _animation, child: widget.child);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/lib/src/screens/splash/base_splash.dart:
--------------------------------------------------------------------------------
1 | library splash;
2 |
3 | import 'dart:async';
4 | import 'dart:io';
5 |
6 | import 'package:azure_devops/src/extensions/context_extension.dart';
7 | import 'package:azure_devops/src/router/router.dart';
8 | import 'package:azure_devops/src/services/azure_api_service.dart';
9 | import 'package:azure_devops/src/services/msal_service.dart';
10 | import 'package:azure_devops/src/services/overlay_service.dart';
11 | import 'package:azure_devops/src/services/storage_service.dart';
12 | import 'package:azure_devops/src/utils/utils.dart';
13 | import 'package:azure_devops/src/widgets/app_base_page.dart';
14 | import 'package:azure_devops/src/widgets/app_page.dart';
15 | import 'package:flutter/material.dart';
16 |
17 | part 'components_splash.dart';
18 | part 'controller_splash.dart';
19 | part 'parameters_splash.dart';
20 | part 'screen_splash.dart';
21 |
22 | class SplashPage extends StatelessWidget {
23 | const SplashPage();
24 |
25 | static const _smartphoneParameters = _SplashParameters();
26 | static const _tabletParameters = _SplashParameters();
27 |
28 | @override
29 | Widget build(BuildContext context) {
30 | return AppBasePage(
31 | initState: () => _SplashController._(context.api),
32 | smartphone: (ctrl) => _SplashScreen(ctrl, _smartphoneParameters),
33 | tablet: (ctrl) => _SplashScreen(ctrl, _tabletParameters),
34 | );
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/lib/src/screens/pipeline_logs/base_pipeline_logs.dart:
--------------------------------------------------------------------------------
1 | library pipeline_logs;
2 |
3 | import 'package:azure_devops/src/extensions/context_extension.dart';
4 | import 'package:azure_devops/src/extensions/datetime_extension.dart';
5 | import 'package:azure_devops/src/mixins/share_mixin.dart';
6 | import 'package:azure_devops/src/router/router.dart';
7 | import 'package:azure_devops/src/services/azure_api_service.dart';
8 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
9 | import 'package:azure_devops/src/widgets/app_base_page.dart';
10 | import 'package:azure_devops/src/widgets/app_page.dart';
11 | import 'package:flutter/material.dart';
12 |
13 | part 'components_pipeline_logs.dart';
14 | part 'controller_pipeline_logs.dart';
15 | part 'parameters_pipeline_logs.dart';
16 | part 'screen_pipeline_logs.dart';
17 |
18 | class PipelineLogsPage extends StatelessWidget {
19 | const PipelineLogsPage();
20 |
21 | static const _smartphoneParameters = _PipelineLogsParameters();
22 | static const _tabletParameters = _PipelineLogsParameters();
23 |
24 | @override
25 | Widget build(BuildContext context) {
26 | final args = AppRouter.getPipelineLogsArgs(context);
27 | return AppBasePage(
28 | initState: () => _PipelineLogsController._(context.api, args),
29 | smartphone: (ctrl) => _PipelineLogsScreen(ctrl, _smartphoneParameters),
30 | tablet: (ctrl) => _PipelineLogsScreen(ctrl, _tabletParameters),
31 | );
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/lib/src/screens/pipeline_logs/controller_pipeline_logs.dart:
--------------------------------------------------------------------------------
1 | part of pipeline_logs;
2 |
3 | class _PipelineLogsController with ShareMixin {
4 | _PipelineLogsController._(this.api, this.args);
5 |
6 | final AzureApiService api;
7 | final PipelineLogsArgs args;
8 |
9 | final logs = ValueNotifier?>(null);
10 |
11 | Future init() async {
12 | final res = await api.getPipelineTaskLogs(
13 | projectName: args.project,
14 | pipelineId: args.pipelineId,
15 | logId: args.logId,
16 | );
17 |
18 | logs.value = res;
19 | }
20 |
21 | String trimDate(String line) {
22 | return line.length >= 28 && DateTime.tryParse(line.substring(0, 28)) != null
23 | ? line.replaceRange(0, 11, '').replaceRange(8, 17, '')
24 | : line;
25 | }
26 |
27 | Color? logColor(String l) {
28 | if (!l.contains('##')) return null;
29 |
30 | if (l.contains('##[warning]')) return Colors.orange;
31 | if (l.contains('##[error]')) return Colors.red;
32 | if (l.contains('##[section]')) return Colors.green;
33 | if (l.contains('##[debug]')) return Colors.purple;
34 | if (l.contains('##[command]')) return Colors.lightBlue;
35 |
36 | return null;
37 | }
38 |
39 | String _getBuildWebUrl() {
40 | return '${api.basePath}/${args.project}/_build/results?buildId=${args.pipelineId}&view=logs&j=${args.parentTaskId}&t=${args.taskId}';
41 | }
42 |
43 | void shareLogs() {
44 | shareUrl(_getBuildWebUrl());
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/lib/src/models/identity_response.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:http/http.dart';
4 |
5 | class IdentityResponse {
6 | IdentityResponse({required this.results});
7 |
8 | factory IdentityResponse.fromJson(Map json) => IdentityResponse(
9 | results: List<_IdentityResult>.from(
10 | (json['results'] as List).map((r) => _IdentityResult.fromJson(r as Map)),
11 | ),
12 | );
13 |
14 | static List<_IdentityResult> fromResponse(Response res) =>
15 | IdentityResponse.fromJson(json.decode(res.body) as Map).results;
16 |
17 | final List<_IdentityResult> results;
18 | }
19 |
20 | class _IdentityResult {
21 | _IdentityResult({required this.queryToken, required this.identities});
22 |
23 | factory _IdentityResult.fromJson(Map json) => _IdentityResult(
24 | queryToken: json['queryToken'] as String,
25 | identities: List.from(
26 | (json['identities'] as List).map((i) => Identity.fromJson(i as Map)),
27 | ),
28 | );
29 |
30 | final String queryToken;
31 | final List identities;
32 | }
33 |
34 | class Identity {
35 | Identity({required this.displayName, required this.mail});
36 |
37 | factory Identity.fromJson(Map json) =>
38 | Identity(displayName: json['displayName'] as String, mail: json['mail'] as String);
39 |
40 | final String displayName;
41 | final String mail;
42 | String? guid;
43 | }
44 |
--------------------------------------------------------------------------------
/android/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | -dontwarn com.google.crypto.tink.subtle.Ed25519Sign$KeyPair
2 | -dontwarn com.google.crypto.tink.subtle.Ed25519Sign
3 | -dontwarn com.google.crypto.tink.subtle.Ed25519Verify
4 | -dontwarn com.google.crypto.tink.subtle.X25519
5 | -dontwarn com.google.crypto.tink.subtle.XChaCha20Poly1305
6 | -dontwarn edu.umd.cs.findbugs.annotations.NonNull
7 | -dontwarn edu.umd.cs.findbugs.annotations.Nullable
8 | -dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings
9 | -dontwarn org.bouncycastle.asn1.ASN1Encodable
10 | -dontwarn org.bouncycastle.asn1.pkcs.PrivateKeyInfo
11 | -dontwarn org.bouncycastle.asn1.x509.AlgorithmIdentifier
12 | -dontwarn org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
13 | -dontwarn org.bouncycastle.cert.X509CertificateHolder
14 | -dontwarn org.bouncycastle.cert.jcajce.JcaX509CertificateHolder
15 | -dontwarn org.bouncycastle.crypto.BlockCipher
16 | -dontwarn org.bouncycastle.crypto.CipherParameters
17 | -dontwarn org.bouncycastle.crypto.InvalidCipherTextException
18 | -dontwarn org.bouncycastle.crypto.engines.AESEngine
19 | -dontwarn org.bouncycastle.crypto.modes.GCMBlockCipher
20 | -dontwarn org.bouncycastle.crypto.params.AEADParameters
21 | -dontwarn org.bouncycastle.crypto.params.KeyParameter
22 | -dontwarn org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider
23 | -dontwarn org.bouncycastle.jce.provider.BouncyCastleProvider
24 | -dontwarn org.bouncycastle.openssl.PEMKeyPair
25 | -dontwarn org.bouncycastle.openssl.PEMParser
26 | -dontwarn org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
--------------------------------------------------------------------------------
/lib/src/extensions/work_item_update_extension.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/models/work_item_updates.dart';
2 |
3 | extension WorkItemUpdateExt on WorkItemUpdate {
4 | bool get hasSupportedChanges {
5 | final fields = this.fields;
6 |
7 | if (fields == null) return false;
8 |
9 | return fields.systemState?.newValue != null ||
10 | fields.systemWorkItemType?.newValue != null ||
11 | fields.systemAssignedTo?.oldValue?.displayName != null ||
12 | fields.systemAssignedTo?.newValue?.displayName != null ||
13 | fields.microsoftVstsSchedulingEffort != null ||
14 | fields.systemTitle != null ||
15 | // show only attachments (not links)
16 | (relations != null && relations!.added != null && relations!.added!.any((r) => r.rel == 'AttachedFile')) ||
17 | (relations != null && relations!.removed != null && relations!.removed!.any((r) => r.rel == 'AttachedFile')) ||
18 | (relations != null && relations!.updated != null && relations!.updated!.any((r) => r.rel == 'AttachedFile'));
19 | }
20 |
21 | bool get hasLinks {
22 | final regexp = RegExp('System.LinkTypes|Microsoft.VSTS.Common.TestedBy');
23 | final hasAddedLinks = relations?.added?.firstOrNull?.rel?.startsWith(regexp) ?? false;
24 | final hasUpdatedLinks = relations?.updated?.firstOrNull?.rel?.startsWith(regexp) ?? false;
25 | final hasRemovedLinks = relations?.removed?.firstOrNull?.rel?.startsWith(regexp) ?? false;
26 | return hasAddedLinks || hasUpdatedLinks || hasRemovedLinks;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/android/app/google-services.json:
--------------------------------------------------------------------------------
1 | {
2 | "project_info": {
3 | "project_number": "project_number",
4 | "project_id": "project_id",
5 | "storage_bucket": "storage_bucket"
6 | },
7 | "client": [
8 | {
9 | "client_info": {
10 | "mobilesdk_app_id": "mobilesdk_app_id",
11 | "android_client_info": {
12 | "package_name": "io.purplesoft.azuredevops"
13 | }
14 | },
15 | "oauth_client": [
16 | {
17 | "client_id": "client_id",
18 | "client_type": 3
19 | }
20 | ],
21 | "api_key": [
22 | {
23 | "current_key": "current_key"
24 | }
25 | ],
26 | "services": {
27 | "appinvite_service": {
28 | "other_platform_oauth_client": [
29 | {
30 | "client_id": "client_id",
31 | "client_type": 3
32 | },
33 | {
34 | "client_id": "client_id",
35 | "client_type": 2,
36 | "ios_info": {
37 | "bundle_id": "bundle_id"
38 | }
39 | }
40 | ]
41 | }
42 | }
43 | }
44 | ],
45 | "configuration_version": "1"
46 | }
--------------------------------------------------------------------------------
/lib/src/widgets/member_avatar.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:azure_devops/src/router/router.dart';
3 | import 'package:cached_network_image/cached_network_image.dart';
4 | import 'package:flutter/material.dart';
5 |
6 | class MemberAvatar extends StatelessWidget {
7 | const MemberAvatar({super.key, required this.userDescriptor, this.imageUrl, this.radius = 40, this.tappable = true});
8 |
9 | final String? imageUrl;
10 | final String? userDescriptor;
11 | final double radius;
12 | final bool tappable;
13 |
14 | Future _goToMemberDetail() async {
15 | await AppRouter.goToMemberDetail(userDescriptor!);
16 | }
17 |
18 | @override
19 | Widget build(BuildContext context) {
20 | if (imageUrl == null && (userDescriptor == null || userDescriptor!.isEmpty)) return const SizedBox();
21 |
22 | final api = context.api;
23 | final url = imageUrl ?? api.getUserAvatarUrl(userDescriptor!);
24 | return InkWell(
25 | onTap: tappable ? _goToMemberDetail : null,
26 | child: ClipRRect(
27 | borderRadius: BorderRadius.circular(100),
28 | child: api.organization.isEmpty
29 | ? const SizedBox()
30 | : CachedNetworkImage(
31 | imageUrl: url,
32 | height: radius,
33 | width: radius,
34 | httpHeaders: api.headers,
35 | errorWidget: (_, _, _) => const SizedBox(),
36 | fit: BoxFit.cover,
37 | ),
38 | ),
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/test/work_item_detail_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/screens/work_item_detail/base_work_item_detail.dart';
2 | import 'package:azure_devops/src/services/ads_service.dart';
3 | import 'package:azure_devops/src/services/azure_api_service.dart';
4 | import 'package:azure_devops/src/services/storage_service.dart';
5 | import 'package:flutter/material.dart';
6 | import 'package:flutter_test/flutter_test.dart';
7 | import 'package:visibility_detector/visibility_detector.dart';
8 |
9 | import 'api_service_mock.dart';
10 |
11 | /// Mock work item is taken from [AzureApiServiceMock.getWorkItemDetail]
12 | void main() {
13 | setUp(() => VisibilityDetectorController.instance.updateInterval = Duration.zero);
14 |
15 | TestWidgetsFlutterBinding.ensureInitialized();
16 |
17 | testWidgets('Page building test', (t) async {
18 | final app = AdsServiceWidget(
19 | ads: AdsServiceMock(),
20 | child: AzureApiServiceWidget(
21 | api: AzureApiServiceMock(),
22 | child: StorageServiceWidget(
23 | storage: StorageServiceMock(),
24 | child: MaterialApp(
25 | theme: mockTheme,
26 | onGenerateRoute: (_) => MaterialPageRoute(
27 | builder: (_) => WorkItemDetailPage(),
28 | settings: RouteSettings(arguments: (project: 'TestProject', id: 1234)),
29 | ),
30 | ),
31 | ),
32 | ),
33 | );
34 |
35 | await t.pumpWidget(app);
36 |
37 | await t.pump();
38 |
39 | expect(find.byType(WorkItemDetailPage), findsOneWidget);
40 | });
41 | }
42 |
--------------------------------------------------------------------------------
/ios/Podfile:
--------------------------------------------------------------------------------
1 | platform :ios, '15.0'
2 |
3 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
4 | ENV['COCOAPODS_DISABLE_STATS'] = 'true'
5 |
6 | project 'Runner', {
7 | 'Debug' => :debug,
8 | 'Profile' => :release,
9 | 'Release' => :release,
10 | }
11 |
12 | def flutter_root
13 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
14 | unless File.exist?(generated_xcode_build_settings_path)
15 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
16 | end
17 |
18 | File.foreach(generated_xcode_build_settings_path) do |line|
19 | matches = line.match(/FLUTTER_ROOT\=(.*)/)
20 | return matches[1].strip if matches
21 | end
22 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
23 | end
24 |
25 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
26 |
27 | flutter_ios_podfile_setup
28 |
29 | target 'Runner' do
30 | use_frameworks!
31 | use_modular_headers!
32 |
33 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
34 | end
35 |
36 | post_install do |installer|
37 | installer.pods_project.targets.each do |target|
38 | flutter_additional_ios_build_settings(target)
39 | target.build_configurations.each do |config|
40 | config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/src/screens/login/base_login.dart:
--------------------------------------------------------------------------------
1 | library login;
2 |
3 | import 'package:azure_devops/src/extensions/context_extension.dart';
4 | import 'package:azure_devops/src/mixins/logger_mixin.dart';
5 | import 'package:azure_devops/src/router/router.dart';
6 | import 'package:azure_devops/src/services/azure_api_service.dart';
7 | import 'package:azure_devops/src/services/msal_service.dart';
8 | import 'package:azure_devops/src/services/overlay_service.dart';
9 | import 'package:azure_devops/src/services/storage_service.dart';
10 | import 'package:azure_devops/src/theme/theme.dart';
11 | import 'package:azure_devops/src/widgets/app_base_page.dart';
12 | import 'package:azure_devops/src/widgets/app_page.dart';
13 | import 'package:azure_devops/src/widgets/form_field.dart';
14 | import 'package:azure_devops/src/widgets/loading_button.dart';
15 | import 'package:flutter/material.dart';
16 | import 'package:url_launcher/link.dart';
17 |
18 | part 'components_login.dart';
19 | part 'controller_login.dart';
20 | part 'parameters_login.dart';
21 | part 'screen_login.dart';
22 |
23 | class LoginPage extends StatelessWidget {
24 | const LoginPage();
25 |
26 | static const _smartphoneParameters = _LoginParameters();
27 | static const _tabletParameters = _LoginParameters();
28 |
29 | @override
30 | Widget build(BuildContext context) {
31 | return AppBasePage(
32 | initState: () => _LoginController._(context.api, context.storage),
33 | smartphone: (ctrl) => _LoginScreen(ctrl, _smartphoneParameters),
34 | tablet: (ctrl) => _LoginScreen(ctrl, _tabletParameters),
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/lib/src/screens/repository_detail/components_repository_detail.dart:
--------------------------------------------------------------------------------
1 | part of repository_detail;
2 |
3 | class _BranchRow extends StatelessWidget {
4 | const _BranchRow({required this.ctrl});
5 |
6 | final _RepositoryDetailController ctrl;
7 |
8 | @override
9 | Widget build(BuildContext context) {
10 | return Row(
11 | children: [
12 | if (ctrl.currentBranch != null)
13 | FilterMenu(
14 | title: 'Branch',
15 | values: ctrl.branches,
16 | currentFilter: ctrl.currentBranch,
17 | onSelected: ctrl.changeBranch,
18 | formatLabel: (b) => '${b.name} ${b.isBaseVersion ? '(default)' : ''}',
19 | isDefaultFilter: false,
20 | widgetBuilder: (_) => const Icon(DevOpsIcons.merge),
21 | ),
22 | const Spacer(),
23 | if (ctrl.currentBranch != null && ctrl.currentBranch!.behindCount > 0) ...[
24 | Icon(Icons.remove, size: 12, color: Colors.red),
25 | Text(
26 | ctrl.currentBranch!.behindCount.toString(),
27 | style: context.textTheme.titleSmall!.copyWith(color: Colors.red),
28 | ),
29 | const SizedBox(width: 10),
30 | ],
31 | if (ctrl.currentBranch != null && ctrl.currentBranch!.aheadCount > 0) ...[
32 | Icon(DevOpsIcons.plus, size: 12, color: Colors.green),
33 | Text(
34 | ctrl.currentBranch!.aheadCount.toString(),
35 | style: context.textTheme.titleSmall!.copyWith(color: Colors.green),
36 | ),
37 | ],
38 | ],
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/test/pull_request_detail_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/screens/pull_request_detail/base_pull_request_detail.dart';
2 | import 'package:azure_devops/src/services/ads_service.dart';
3 | import 'package:azure_devops/src/services/azure_api_service.dart';
4 | import 'package:azure_devops/src/services/storage_service.dart';
5 | import 'package:flutter/material.dart';
6 | import 'package:flutter_test/flutter_test.dart';
7 | import 'package:visibility_detector/visibility_detector.dart';
8 |
9 | import 'api_service_mock.dart';
10 |
11 | /// Mock pull request is taken from [AzureApiServiceMock.getPullRequest]
12 | void main() {
13 | setUp(() => VisibilityDetectorController.instance.updateInterval = Duration.zero);
14 |
15 | TestWidgetsFlutterBinding.ensureInitialized();
16 |
17 | testWidgets('Page building test', (t) async {
18 | final app = AdsServiceWidget(
19 | ads: AdsServiceMock(),
20 | child: AzureApiServiceWidget(
21 | api: AzureApiServiceMock(),
22 | child: StorageServiceWidget(
23 | storage: StorageServiceMock(),
24 | child: MaterialApp(
25 | theme: mockTheme,
26 | onGenerateRoute: (_) => MaterialPageRoute(
27 | builder: (_) => PullRequestDetailPage(),
28 | settings: RouteSettings(arguments: (id: 1234, project: 'TestProject', repository: 'TestRepo')),
29 | ),
30 | ),
31 | ),
32 | ),
33 | );
34 |
35 | await t.pumpWidget(app);
36 |
37 | await t.pump();
38 |
39 | expect(find.byType(PullRequestDetailPage), findsOneWidget);
40 | });
41 | }
42 |
--------------------------------------------------------------------------------
/lib/src/services/amazon_service.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/reponse_extension.dart';
2 | import 'package:azure_devops/src/mixins/logger_mixin.dart';
3 | import 'package:azure_devops/src/models/amazon/amazon_item.dart';
4 | import 'package:http/http.dart';
5 | import 'package:sentry_flutter/sentry_flutter.dart';
6 |
7 | class AmazonService with AppLogger {
8 | factory AmazonService() {
9 | return instance ??= AmazonService._();
10 | }
11 |
12 | AmazonService._() {
13 | setTag('AmazonService');
14 | }
15 |
16 | static AmazonService? instance;
17 |
18 | final _client = SentryHttpClient();
19 |
20 | List? _items;
21 |
22 | static const _url = 'https://products.azdevops.app/api/products?category=all';
23 |
24 | var _lastFetchTime = DateTime.now();
25 |
26 | void dispose() {
27 | instance = null;
28 | }
29 |
30 | Future> getItems() async {
31 | if (_items != null && _hasFetchedRecently()) return _items!;
32 |
33 | final Response jsonsRes;
34 |
35 | try {
36 | jsonsRes = await _client.get(Uri.parse(_url));
37 | } catch (e, s) {
38 | logError(e, s);
39 | return [];
40 | }
41 |
42 | if (jsonsRes.isError) {
43 | logErrorMessage('Error fetching items');
44 | return [];
45 | }
46 |
47 | _lastFetchTime = DateTime.now();
48 |
49 | final items = AmazonItem.listFromJson(jsonsRes.body);
50 | logDebug('Fetched ${items.length} items.');
51 |
52 | return _items = items;
53 | }
54 |
55 | bool _hasFetchedRecently() => _lastFetchTime.isAfter(DateTime.now().subtract(Duration(hours: 1)));
56 | }
57 |
--------------------------------------------------------------------------------
/lib/src/screens/choose_subscription/base_choose_subscription.dart:
--------------------------------------------------------------------------------
1 | library choose_subscription;
2 |
3 | import 'dart:async';
4 |
5 | import 'package:azure_devops/src/extensions/context_extension.dart';
6 | import 'package:azure_devops/src/extensions/string_extension.dart';
7 | import 'package:azure_devops/src/router/router.dart';
8 | import 'package:azure_devops/src/services/azure_api_service.dart';
9 | import 'package:azure_devops/src/services/overlay_service.dart';
10 | import 'package:azure_devops/src/services/purchase_service.dart';
11 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
12 | import 'package:azure_devops/src/widgets/app_base_page.dart';
13 | import 'package:azure_devops/src/widgets/app_page.dart';
14 | import 'package:azure_devops/src/widgets/loading_button.dart';
15 | import 'package:flutter/material.dart';
16 |
17 | part 'components_choose_subscription.dart';
18 | part 'controller_choose_subscription.dart';
19 | part 'screen_choose_subscription.dart';
20 |
21 | typedef _ChooseSubscriptionParameters = ();
22 |
23 | class ChooseSubscriptionPage extends StatelessWidget {
24 | const ChooseSubscriptionPage();
25 |
26 | static const _ChooseSubscriptionParameters _smartphoneParameters = ();
27 | static const _ChooseSubscriptionParameters _tabletParameters = ();
28 |
29 | @override
30 | Widget build(BuildContext context) {
31 | return AppBasePage(
32 | initState: () => _ChooseSubscriptionController._(context.purchase),
33 | smartphone: (ctrl) => _ChooseSubscriptionScreen(ctrl, _smartphoneParameters),
34 | tablet: (ctrl) => _ChooseSubscriptionScreen(ctrl, _tabletParameters),
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 |
--------------------------------------------------------------------------------
/lib/src/mixins/ads_mixin.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/models/amazon/amazon_item.dart';
2 | import 'package:azure_devops/src/services/ads_service.dart';
3 | import 'package:azure_devops/src/widgets/ad_widget.dart';
4 | import 'package:flutter/widgets.dart';
5 |
6 | mixin AdsMixin {
7 | List nativeAds = [];
8 | List amazonAds = [];
9 | var _hasAmazonAds = false;
10 |
11 | Future getNewNativeAds(AdsService ads) async {
12 | _hasAmazonAds = ads.hasAmazonAds;
13 |
14 | if (_hasAmazonAds) {
15 | final items = await _getNewAmazonAds(ads);
16 | if (items.isEmpty) {
17 | _hasAmazonAds = false;
18 | await _getNewAdmobAds(ads);
19 | }
20 | } else {
21 | await _getNewAdmobAds(ads);
22 | }
23 | }
24 |
25 | /// Load new native ads and map them to [AdWithKey] objects with a new global key to force refresh the UI.
26 | Future _getNewAdmobAds(AdsService ads) async {
27 | final newAds = await ads.getNewNativeAds();
28 | nativeAds = newAds.map((ad) => (ad: ad, key: GlobalKey())).toList();
29 | }
30 |
31 | Future> _getNewAmazonAds(AdsService ads) async {
32 | final newAmazonAds = await ads.getNewAmazonAds();
33 | return amazonAds = newAmazonAds.toList();
34 | }
35 |
36 | /// Whether to show a native ad at the given [index] inside [items] list.
37 | bool shouldShowNativeAd(List items, T item, int index) =>
38 | items.indexOf(item) % 5 == 4 && item != items.first && index < (_hasAmazonAds ? amazonAds : nativeAds).length;
39 |
40 | Future showInterstitialAd(AdsService ads, {VoidCallback? onDismiss}) async {
41 | await ads.showInterstitialAd(onDismiss: onDismiss);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/lib/src/models/team.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:azure_devops/src/models/team_member.dart';
4 | import 'package:http/http.dart';
5 |
6 | typedef TeamWithMembers = ({Team team, List members});
7 |
8 | class GetTeamsResponse {
9 | GetTeamsResponse({required this.teams});
10 |
11 | factory GetTeamsResponse.fromJson(Map json) => GetTeamsResponse(
12 | teams: List.from(
13 | (json['value'] as List? ?? []).map((x) => Team.fromJson(x as Map)),
14 | ),
15 | );
16 |
17 | static List fromResponse(Response res) =>
18 | GetTeamsResponse.fromJson(jsonDecode(res.body) as Map).teams;
19 |
20 | final List teams;
21 | }
22 |
23 | class Team {
24 | Team({
25 | required this.id,
26 | required this.name,
27 | required this.description,
28 | required this.projectName,
29 | required this.projectId,
30 | });
31 |
32 | factory Team.fromJson(Map json) => Team(
33 | id: json['id'] as String? ?? '',
34 | name: json['name'] as String? ?? '',
35 | description: json['description'] as String? ?? '',
36 | projectName: json['projectName'] as String? ?? '',
37 | projectId: json['projectId'] as String? ?? '',
38 | );
39 |
40 | final String id;
41 | final String name;
42 | final String description;
43 | final String projectName;
44 | final String projectId;
45 |
46 | @override
47 | bool operator ==(covariant Team other) {
48 | if (identical(this, other)) return true;
49 |
50 | return other.id == id && other.projectId == projectId;
51 | }
52 |
53 | @override
54 | int get hashCode {
55 | return id.hashCode ^ projectId.hashCode;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/lib/src/screens/file_detail/base_file_detail.dart:
--------------------------------------------------------------------------------
1 | library file_detail;
2 |
3 | import 'dart:typed_data';
4 |
5 | import 'package:azure_devops/src/extensions/context_extension.dart';
6 | import 'package:azure_devops/src/mixins/share_mixin.dart';
7 | import 'package:azure_devops/src/router/router.dart';
8 | import 'package:azure_devops/src/services/azure_api_service.dart';
9 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
10 | import 'package:azure_devops/src/widgets/app_base_page.dart';
11 | import 'package:azure_devops/src/widgets/app_page.dart';
12 | import 'package:azure_devops/src/widgets/markdown_widget.dart';
13 | import 'package:flutter/material.dart';
14 | import 'package:flutter_highlighting/flutter_highlighting.dart';
15 | import 'package:flutter_markdown/flutter_markdown.dart';
16 | import 'package:highlighting/highlighting.dart';
17 | import 'package:highlighting/languages/all.dart';
18 | import 'package:highlighting/src/language.dart';
19 |
20 | part 'components_file_detail.dart';
21 | part 'controller_file_detail.dart';
22 | part 'parameters_file_detail.dart';
23 | part 'screen_file_detail.dart';
24 |
25 | class FileDetailPage extends StatelessWidget {
26 | const FileDetailPage();
27 |
28 | static const _smartphoneParameters = _FileDetailParameters();
29 | static const _tabletParameters = _FileDetailParameters();
30 |
31 | @override
32 | Widget build(BuildContext context) {
33 | final args = AppRouter.getFileDetailArgs(context);
34 | return AppBasePage(
35 | initState: () => _FileDetailController._(context.api, args),
36 | smartphone: (ctrl) => _FileDetailScreen(ctrl, _smartphoneParameters),
37 | tablet: (ctrl) => _FileDetailScreen(ctrl, _tabletParameters),
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/ios/AzDevopsShareExt/Base.lproj/MainInterface.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 |
--------------------------------------------------------------------------------
/lib/src/screens/saved_queries/base_saved_queries.dart:
--------------------------------------------------------------------------------
1 | library saved_queries;
2 |
3 | import 'package:azure_devops/src/extensions/context_extension.dart';
4 | import 'package:azure_devops/src/mixins/ads_mixin.dart';
5 | import 'package:azure_devops/src/models/saved_query.dart';
6 | import 'package:azure_devops/src/router/router.dart';
7 | import 'package:azure_devops/src/services/ads_service.dart';
8 | import 'package:azure_devops/src/services/azure_api_service.dart';
9 | import 'package:azure_devops/src/services/overlay_service.dart';
10 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
11 | import 'package:azure_devops/src/widgets/app_base_page.dart';
12 | import 'package:azure_devops/src/widgets/app_page.dart';
13 | import 'package:azure_devops/src/widgets/navigation_button.dart';
14 | import 'package:azure_devops/src/widgets/popup_menu.dart';
15 | import 'package:collection/collection.dart';
16 | import 'package:flutter/material.dart';
17 |
18 | part 'components_saved_queries.dart';
19 | part 'controller_saved_queries.dart';
20 | part 'screen_saved_queries.dart';
21 |
22 | typedef _SavedQueriesParameters = ();
23 |
24 | class SavedQueriesPage extends StatelessWidget {
25 | const SavedQueriesPage();
26 |
27 | static const _SavedQueriesParameters _smartphoneParameters = ();
28 | static const _SavedQueriesParameters _tabletParameters = ();
29 |
30 | @override
31 | Widget build(BuildContext context) {
32 | final args = AppRouter.getSavedQueriesArgs(context);
33 | return AppBasePage(
34 | initState: () => _SavedQueriesController._(args, context.api, context.ads),
35 | smartphone: (ctrl) => _SavedQueriesScreen(ctrl, _smartphoneParameters),
36 | tablet: (ctrl) => _SavedQueriesScreen(ctrl, _tabletParameters),
37 | );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/lib/src/widgets/section_header.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:flutter/material.dart';
3 |
4 | class SectionHeader extends StatelessWidget {
5 | const SectionHeader({
6 | required this.text,
7 | this.textHeight,
8 | this.iconSize,
9 | this.mainAxisAlignment = MainAxisAlignment.start,
10 | }) : icon = null,
11 | marginTop = 24;
12 |
13 | const SectionHeader.withIcon({
14 | required this.text,
15 | required this.icon,
16 | this.marginTop = 24,
17 | this.textHeight,
18 | this.iconSize,
19 | this.mainAxisAlignment = MainAxisAlignment.start,
20 | });
21 |
22 | const SectionHeader.noMargin({
23 | required this.text,
24 | this.icon,
25 | this.textHeight,
26 | this.iconSize,
27 | this.mainAxisAlignment = MainAxisAlignment.start,
28 | }) : marginTop = 0;
29 |
30 | final String text;
31 | final IconData? icon;
32 | final double? iconSize;
33 | final double marginTop;
34 | final MainAxisAlignment mainAxisAlignment;
35 |
36 | /// Used to align [SectionHeader] inside a row
37 | final double? textHeight;
38 |
39 | @override
40 | Widget build(BuildContext context) {
41 | Widget body = Text(
42 | text,
43 | style: context.textTheme.headlineSmall!.copyWith(height: textHeight),
44 | overflow: TextOverflow.ellipsis,
45 | );
46 |
47 | if (icon != null) {
48 | body = Row(
49 | mainAxisAlignment: mainAxisAlignment,
50 | children: [
51 | Icon(icon, size: iconSize),
52 | const SizedBox(width: 12),
53 | Flexible(child: body),
54 | ],
55 | );
56 | }
57 |
58 | return Padding(
59 | padding: EdgeInsets.only(top: marginTop, bottom: 12),
60 | child: body,
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/lib/src/screens/member_detail/base_member_detail.dart:
--------------------------------------------------------------------------------
1 | library member_detail;
2 |
3 | import 'package:azure_devops/src/extensions/commit_extension.dart';
4 | import 'package:azure_devops/src/extensions/context_extension.dart';
5 | import 'package:azure_devops/src/models/commit.dart';
6 | import 'package:azure_devops/src/models/user.dart';
7 | import 'package:azure_devops/src/router/router.dart';
8 | import 'package:azure_devops/src/services/azure_api_service.dart';
9 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
10 | import 'package:azure_devops/src/widgets/app_base_page.dart';
11 | import 'package:azure_devops/src/widgets/app_page.dart';
12 | import 'package:azure_devops/src/widgets/commit_list_tile.dart';
13 | import 'package:azure_devops/src/widgets/member_avatar.dart';
14 | import 'package:azure_devops/src/widgets/section_header.dart';
15 | import 'package:azure_devops/src/widgets/text_title_description.dart';
16 | import 'package:flutter/material.dart';
17 | import 'package:url_launcher/link.dart';
18 |
19 | part 'components_member_detail.dart';
20 | part 'controller_member_detail.dart';
21 | part 'parameters_member_detail.dart';
22 | part 'screen_member_detail.dart';
23 |
24 | class MemberDetailPage extends StatelessWidget {
25 | const MemberDetailPage();
26 |
27 | static const _smartphoneParameters = _MemberDetailParameters();
28 | static const _tabletParameters = _MemberDetailParameters();
29 |
30 | @override
31 | Widget build(BuildContext context) {
32 | final member = AppRouter.getMemberDetailArgs(context);
33 | return AppBasePage(
34 | initState: () => _MemberDetailController._(member, context.api),
35 | smartphone: (ctrl) => _MemberDetailScreen(ctrl, _smartphoneParameters),
36 | tablet: (ctrl) => _MemberDetailScreen(ctrl, _tabletParameters),
37 | );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/lib/src/screens/repository_detail/base_repository_detail.dart:
--------------------------------------------------------------------------------
1 | library repository_detail;
2 |
3 | import 'package:azure_devops/src/extensions/commit_extension.dart';
4 | import 'package:azure_devops/src/extensions/context_extension.dart';
5 | import 'package:azure_devops/src/models/commit.dart';
6 | import 'package:azure_devops/src/models/repository_branches.dart';
7 | import 'package:azure_devops/src/models/repository_items.dart';
8 | import 'package:azure_devops/src/router/router.dart';
9 | import 'package:azure_devops/src/services/azure_api_service.dart';
10 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
11 | import 'package:azure_devops/src/widgets/app_base_page.dart';
12 | import 'package:azure_devops/src/widgets/app_page.dart';
13 | import 'package:azure_devops/src/widgets/filter_menu.dart';
14 | import 'package:azure_devops/src/widgets/navigation_button.dart';
15 | import 'package:collection/collection.dart';
16 | import 'package:flutter/material.dart';
17 |
18 | part 'components_repository_detail.dart';
19 | part 'controller_repository_detail.dart';
20 | part 'parameters_repository_detail.dart';
21 | part 'screen_repository_detail.dart';
22 |
23 | class RepositoryDetailPage extends StatelessWidget {
24 | const RepositoryDetailPage();
25 |
26 | static const _smartphoneParameters = _RepositoryDetailParameters();
27 | static const _tabletParameters = _RepositoryDetailParameters();
28 |
29 | @override
30 | Widget build(BuildContext context) {
31 | final args = AppRouter.getRepositoryDetailArgs(context);
32 | return AppBasePage(
33 | initState: () => _RepositoryDetailController._(context.api, args),
34 | smartphone: (ctrl) => _RepositoryDetailScreen(ctrl, _smartphoneParameters),
35 | tablet: (ctrl) => _RepositoryDetailScreen(ctrl, _tabletParameters),
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/lib/src/screens/project_boards/base_project_boards.dart:
--------------------------------------------------------------------------------
1 | library project_boards;
2 |
3 | import 'package:azure_devops/src/extensions/context_extension.dart';
4 | import 'package:azure_devops/src/extensions/string_extension.dart';
5 | import 'package:azure_devops/src/models/board.dart';
6 | import 'package:azure_devops/src/models/sprint.dart';
7 | import 'package:azure_devops/src/models/team.dart';
8 | import 'package:azure_devops/src/router/router.dart';
9 | import 'package:azure_devops/src/services/azure_api_service.dart';
10 | import 'package:azure_devops/src/services/overlay_service.dart';
11 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
12 | import 'package:azure_devops/src/widgets/app_base_page.dart';
13 | import 'package:azure_devops/src/widgets/app_page.dart';
14 | import 'package:azure_devops/src/widgets/navigation_button.dart';
15 | import 'package:azure_devops/src/widgets/section_header.dart';
16 | import 'package:collection/collection.dart';
17 | import 'package:flutter/material.dart';
18 |
19 | part 'components_project_boards.dart';
20 | part 'controller_project_boards.dart';
21 | part 'screen_project_boards.dart';
22 |
23 | typedef _ProjectBoardsParameters = ();
24 |
25 | class ProjectBoardsPage extends StatelessWidget {
26 | const ProjectBoardsPage();
27 |
28 | static const _ProjectBoardsParameters _smartphoneParameters = ();
29 | static const _ProjectBoardsParameters _tabletParameters = ();
30 |
31 | @override
32 | Widget build(BuildContext context) {
33 | final projectName = AppRouter.getProjectBoardsArgs(context);
34 | return AppBasePage(
35 | initState: () => _ProjectBoardsController._(context.api, projectName),
36 | smartphone: (ctrl) => _ProjectBoardsScreen(ctrl, _smartphoneParameters),
37 | tablet: (ctrl) => _ProjectBoardsScreen(ctrl, _tabletParameters),
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/lib/src/models/repository_items.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:http/http.dart';
4 |
5 | class GetRepoItemsResponse {
6 | GetRepoItemsResponse({required this.count, required this.repoItems});
7 |
8 | factory GetRepoItemsResponse.fromJson(Map json) => GetRepoItemsResponse(
9 | count: json['count'] as int,
10 | repoItems: List.from(
11 | (json['value'] as List).map((i) => RepoItem.fromJson(i as Map)),
12 | ),
13 | );
14 |
15 | static List fromResponse(Response res) =>
16 | GetRepoItemsResponse.fromJson(json.decode(res.body) as Map).repoItems;
17 |
18 | final int count;
19 | final List repoItems;
20 | }
21 |
22 | class RepoItem {
23 | RepoItem({
24 | required this.objectId,
25 | required this.commitId,
26 | required this.path,
27 | this.isFolder = false,
28 | this.contentMetadata,
29 | required this.url,
30 | });
31 |
32 | factory RepoItem.fromJson(Map json) => RepoItem(
33 | objectId: json['objectId'] as String,
34 | commitId: json['commitId'] as String,
35 | path: json['path'] as String,
36 | isFolder: json['isFolder'] as bool? ?? false,
37 | contentMetadata: json['contentMetadata'] == null
38 | ? null
39 | : _ContentMetadata.fromJson(json['contentMetadata'] as Map),
40 | url: json['url'] as String,
41 | );
42 |
43 | final String objectId;
44 | final String commitId;
45 | final String path;
46 | final bool isFolder;
47 | final _ContentMetadata? contentMetadata;
48 | final String url;
49 | }
50 |
51 | class _ContentMetadata {
52 | _ContentMetadata({required this.fileName});
53 |
54 | factory _ContentMetadata.fromJson(Map json) =>
55 | _ContentMetadata(fileName: json['fileName'] as String);
56 |
57 | final String fileName;
58 | }
59 |
--------------------------------------------------------------------------------
/lib/src/widgets/project_card.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:azure_devops/src/models/project.dart';
3 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
4 | import 'package:azure_devops/src/widgets/navigation_button.dart';
5 | import 'package:cached_network_image/cached_network_image.dart';
6 | import 'package:flutter/material.dart';
7 |
8 | class ProjectCard extends StatelessWidget {
9 | const ProjectCard({required this.height, required this.project, required this.onTap});
10 |
11 | final double? height;
12 | final Project project;
13 | final void Function(Project p) onTap;
14 |
15 | @override
16 | Widget build(BuildContext context) {
17 | final api = context.api;
18 | return SizedBox(
19 | height: height,
20 | child: NavigationButton(
21 | margin: const EdgeInsets.only(top: 8),
22 | inkwellKey: ValueKey(project.name),
23 | padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
24 | onTap: () => onTap(project),
25 | child: Row(
26 | children: [
27 | ClipRRect(
28 | borderRadius: BorderRadius.circular(100),
29 | child: api.isImageUnauthorized
30 | ? SizedBox(height: 30, width: 30, child: Icon(DevOpsIcons.project))
31 | : CachedNetworkImage(
32 | imageUrl: project.defaultTeamImageUrl!,
33 | httpHeaders: api.headers,
34 | errorWidget: (_, _, _) => Icon(DevOpsIcons.project),
35 | width: 30,
36 | height: 30,
37 | ),
38 | ),
39 | const SizedBox(width: 15),
40 | Expanded(child: Text(project.name!)),
41 | Icon(Icons.arrow_forward_ios),
42 | ],
43 | ),
44 | ),
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/lib/src/screens/choose_subscription/controller_choose_subscription.dart:
--------------------------------------------------------------------------------
1 | part of choose_subscription;
2 |
3 | class _ChooseSubscriptionController {
4 | _ChooseSubscriptionController._(this.purchase);
5 |
6 | final PurchaseService purchase;
7 |
8 | final products = ValueNotifier>?>(null);
9 |
10 | final isPurchasing = ValueNotifier(false);
11 | final purchasingMap = >{};
12 |
13 | Future init() async {
14 | final productsRes = await purchase.getProducts();
15 |
16 | for (final product in productsRes) {
17 | purchasingMap[product.id] = ValueNotifier(false);
18 | }
19 |
20 | products.value = ApiResponse.ok(productsRes);
21 | }
22 |
23 | Future purchaseProduct(AppProduct product) async {
24 | isPurchasing.value = true;
25 | purchasingMap[product.id]!.value = true;
26 |
27 | final res = await purchase.buySubscription(product);
28 |
29 | purchasingMap[product.id]!.value = false;
30 | isPurchasing.value = false;
31 |
32 | switch (res) {
33 | case PurchaseResult.success:
34 | OverlayService.snackbar('${product.title} subscription successfully purchased');
35 | unawaited(AppRouter.goToSplash());
36 | return;
37 | case PurchaseResult.failed:
38 | return OverlayService.error('Error', description: 'Subscription purchase failed');
39 | case PurchaseResult.cancelled:
40 | }
41 | }
42 |
43 | Future restorePurchase() async {
44 | isPurchasing.value = true;
45 |
46 | final res = await purchase.restorePurchases();
47 |
48 | isPurchasing.value = false;
49 |
50 | if (!res) {
51 | return OverlayService.error('Error', description: 'No previous subscription found');
52 | }
53 |
54 | OverlayService.snackbar('Subscription successfully restored');
55 | unawaited(AppRouter.goToSplash());
56 | return;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/lib/src/screens/choose_projects/base_choose_projects.dart:
--------------------------------------------------------------------------------
1 | library choose_projects;
2 |
3 | import 'dart:async';
4 | import 'dart:math';
5 |
6 | import 'package:azure_devops/src/extensions/context_extension.dart';
7 | import 'package:azure_devops/src/models/organization.dart';
8 | import 'package:azure_devops/src/models/project.dart';
9 | import 'package:azure_devops/src/router/router.dart';
10 | import 'package:azure_devops/src/services/azure_api_service.dart';
11 | import 'package:azure_devops/src/services/msal_service.dart';
12 | import 'package:azure_devops/src/services/overlay_service.dart';
13 | import 'package:azure_devops/src/services/storage_service.dart';
14 | import 'package:azure_devops/src/utils/utils.dart';
15 | import 'package:azure_devops/src/widgets/app_base_page.dart';
16 | import 'package:azure_devops/src/widgets/app_page.dart';
17 | import 'package:azure_devops/src/widgets/loading_button.dart';
18 | import 'package:azure_devops/src/widgets/search_field.dart';
19 | import 'package:azure_devops/src/widgets/section_header.dart';
20 | import 'package:flutter/material.dart';
21 |
22 | part 'components_choose_projects.dart';
23 | part 'controller_choose_projects.dart';
24 | part 'parameters_choose_projects.dart';
25 | part 'screen_choose_projects.dart';
26 |
27 | class ChooseProjectsPage extends StatelessWidget {
28 | const ChooseProjectsPage();
29 |
30 | static const _smartphoneParameters = _ChooseProjectsParameters();
31 | static const _tabletParameters = _ChooseProjectsParameters();
32 |
33 | @override
34 | Widget build(BuildContext context) {
35 | final removeRoutes = AppRouter.getChooseProjectArgs(context);
36 | return AppBasePage(
37 | initState: () => _ChooseProjectsController._(context.api, removeRoutes, context.storage),
38 | smartphone: (ctrl) => _ChooseProjectsScreen(ctrl, _smartphoneParameters),
39 | tablet: (ctrl) => _ChooseProjectsScreen(ctrl, _tabletParameters),
40 | );
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/lib/src/screens/repository_detail/controller_repository_detail.dart:
--------------------------------------------------------------------------------
1 | part of repository_detail;
2 |
3 | class _RepositoryDetailController {
4 | _RepositoryDetailController._(this.api, this.args);
5 |
6 | final RepoDetailArgs args;
7 | final AzureApiService api;
8 |
9 | final repoItems = ValueNotifier?>?>(null);
10 |
11 | List branches = [];
12 |
13 | Branch? currentBranch;
14 |
15 | Future init() async {
16 | final branchesRes = await api.getRepositoryBranches(projectName: args.projectName, repoName: args.repositoryName);
17 |
18 | branches = branchesRes.data ?? [];
19 |
20 | if (branches.isNotEmpty) {
21 | currentBranch = args.branch != null
22 | ? branches.firstWhereOrNull((b) => b.name == args.branch)
23 | : branches.firstWhereOrNull((b) => b.isBaseVersion);
24 | branches = [currentBranch!, ...branches.where((b) => b != currentBranch)];
25 | }
26 |
27 | await _getRepoItems();
28 | }
29 |
30 | Future _getRepoItems() async {
31 | final itemsRes = await api.getRepositoryItems(
32 | projectName: args.projectName,
33 | repoName: args.repositoryName,
34 | path: args.filePath ?? '/',
35 | branch: currentBranch?.name,
36 | );
37 |
38 | repoItems.value = itemsRes;
39 | }
40 |
41 | void goToCommitDetail(Commit commit) {
42 | AppRouter.goToCommitDetail(
43 | project: commit.projectName,
44 | repository: commit.repositoryName,
45 | commitId: commit.commitId!,
46 | );
47 | }
48 |
49 | void goToItem(RepoItem item) {
50 | final newArgs = args.copyWith(filePath: item.path, branch: currentBranch?.name);
51 | if (item.isFolder) {
52 | AppRouter.goToRepositoryDetail(newArgs);
53 | } else {
54 | AppRouter.goToFileDetail(newArgs);
55 | }
56 | }
57 |
58 | void changeBranch(Branch? branch) {
59 | currentBranch = branch;
60 | _getRepoItems();
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/lib/src/widgets/project_and_repo_chips.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:flutter/material.dart';
3 |
4 | class ProjectChip extends StatelessWidget {
5 | const ProjectChip({required this.onTap, required this.projectName});
6 |
7 | final VoidCallback onTap;
8 | final String projectName;
9 |
10 | @override
11 | Widget build(BuildContext context) {
12 | return _InternalChip(onTap: onTap, title: 'Project:', text: projectName);
13 | }
14 | }
15 |
16 | class RepositoryChip extends StatelessWidget {
17 | const RepositoryChip({required this.onTap, required this.repositoryName});
18 |
19 | final VoidCallback onTap;
20 | final String? repositoryName;
21 |
22 | @override
23 | Widget build(BuildContext context) {
24 | return _InternalChip(onTap: onTap, title: 'Repository:', text: repositoryName);
25 | }
26 | }
27 |
28 | class _InternalChip extends StatelessWidget {
29 | const _InternalChip({required this.onTap, required this.text, required this.title});
30 |
31 | final VoidCallback onTap;
32 | final String title;
33 | final String? text;
34 |
35 | @override
36 | Widget build(BuildContext context) {
37 | return GestureDetector(
38 | onTap: onTap,
39 | child: Row(
40 | mainAxisSize: MainAxisSize.min,
41 | children: [
42 | Text(title, style: context.textTheme.titleSmall!.copyWith(color: context.colorScheme.onSecondary)),
43 | const SizedBox(width: 8),
44 | Flexible(
45 | child: Padding(
46 | padding: const EdgeInsets.symmetric(vertical: 4),
47 | child: Text(
48 | text ?? '-',
49 | style: context.textTheme.titleSmall!.copyWith(
50 | decoration: text == null ? null : TextDecoration.underline,
51 | ),
52 | ),
53 | ),
54 | ),
55 | ],
56 | ),
57 | );
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/lib/src/screens/splash/controller_splash.dart:
--------------------------------------------------------------------------------
1 | part of splash;
2 |
3 | class _SplashController {
4 | _SplashController._(this.api);
5 |
6 | final AzureApiService api;
7 |
8 | static const _splashMinDuration = Duration(milliseconds: 1200);
9 |
10 | late LoginStatus _isLogged;
11 |
12 | String _errorMessage = 'Generic error';
13 |
14 | Future init() async {
15 | final token = StorageServiceCore().getToken();
16 |
17 | // wait at least [_splashMinDuration] before navigating
18 | await Future.wait([Future.delayed(_splashMinDuration), _login(token)]);
19 |
20 | await _init(token);
21 | }
22 |
23 | Future _login(String token) async {
24 | try {
25 | _isLogged = await api.login(token);
26 | } on SocketException catch (_) {
27 | _isLogged = LoginStatus.failed;
28 | _errorMessage = 'Check your internet connection';
29 | } catch (e) {
30 | _isLogged = LoginStatus.failed;
31 | }
32 | }
33 |
34 | Future _init(String token) async {
35 | if (token.isEmpty) {
36 | unawaited(AppRouter.goToLogin());
37 | return;
38 | }
39 |
40 | if (_isLogged == LoginStatus.unauthorized) {
41 | // token is expired
42 | await OverlayService.error('Error', description: 'Token expired');
43 | await api.logout();
44 | await MsalService().logout();
45 |
46 | // Rebuild app to reset dependencies. This is needed to fix user null error after logout and login
47 | rebuildApp();
48 |
49 | unawaited(AppRouter.goToLogin());
50 | return;
51 | }
52 |
53 | if (_isLogged == LoginStatus.failed) {
54 | unawaited(AppRouter.goToError(description: _errorMessage));
55 | return;
56 | }
57 |
58 | if (_isLogged == LoginStatus.projectsNotSet || _isLogged == LoginStatus.orgNotSet) {
59 | unawaited(AppRouter.goToChooseProjects());
60 | return;
61 | }
62 |
63 | unawaited(AppRouter.goToTabs());
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/lib/src/screens/sprint_detail/base_sprint_detail.dart:
--------------------------------------------------------------------------------
1 | library sprint_detail;
2 |
3 | import 'package:azure_devops/src/extensions/context_extension.dart';
4 | import 'package:azure_devops/src/mixins/filter_mixin.dart';
5 | import 'package:azure_devops/src/models/board.dart';
6 | import 'package:azure_devops/src/models/processes.dart';
7 | import 'package:azure_devops/src/models/sprint.dart';
8 | import 'package:azure_devops/src/models/user.dart';
9 | import 'package:azure_devops/src/models/work_items.dart';
10 | import 'package:azure_devops/src/router/router.dart';
11 | import 'package:azure_devops/src/services/azure_api_service.dart';
12 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
13 | import 'package:azure_devops/src/widgets/app_base_page.dart';
14 | import 'package:azure_devops/src/widgets/app_page.dart';
15 | import 'package:azure_devops/src/widgets/board_widget.dart';
16 | import 'package:azure_devops/src/widgets/filter_menu.dart';
17 | import 'package:azure_devops/src/widgets/popup_menu.dart';
18 | import 'package:azure_devops/src/widgets/search_field.dart';
19 | import 'package:collection/collection.dart';
20 | import 'package:flutter/material.dart';
21 |
22 | part 'components_sprint_detail.dart';
23 | part 'controller_sprint_detail.dart';
24 | part 'screen_sprint_detail.dart';
25 |
26 | typedef _SprintDetailParameters = ();
27 |
28 | class SprintDetailPage extends StatelessWidget {
29 | const SprintDetailPage();
30 |
31 | static const _SprintDetailParameters _smartphoneParameters = ();
32 | static const _SprintDetailParameters _tabletParameters = ();
33 |
34 | @override
35 | Widget build(BuildContext context) {
36 | final args = AppRouter.getSprintDetailArgs(context);
37 | return AppBasePage(
38 | initState: () => _SprintDetailController._(context.api, args),
39 | smartphone: (ctrl) => _SprintDetailScreen(ctrl, _smartphoneParameters),
40 | tablet: (ctrl) => _SprintDetailScreen(ctrl, _tabletParameters),
41 | );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/lib/src/widgets/loading_button.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:azure_devops/src/mixins/logger_mixin.dart';
3 | import 'package:flutter/material.dart';
4 |
5 | class LoadingButton extends StatefulWidget {
6 | const LoadingButton({required this.onPressed, required this.text, this.backgroundColor, this.textColor, this.margin});
7 |
8 | final dynamic Function() onPressed;
9 | final String text;
10 | final Color? backgroundColor;
11 | final Color? textColor;
12 | final EdgeInsets? margin;
13 |
14 | @override
15 | State createState() => _LoadingButtonState();
16 | }
17 |
18 | class _LoadingButtonState extends State with AppLogger {
19 | bool _isLoading = false;
20 |
21 | @override
22 | Widget build(BuildContext context) {
23 | return Padding(
24 | padding: widget.margin ?? const EdgeInsets.symmetric(horizontal: 50),
25 | child: MaterialButton(
26 | color: widget.backgroundColor ?? context.colorScheme.primary,
27 | minWidth: double.maxFinite,
28 | shape: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
29 | elevation: 0,
30 | onPressed: () async {
31 | setState(() => _isLoading = true);
32 |
33 | // catch exceptions to avoid infinite loading
34 | try {
35 | await widget.onPressed();
36 | } catch (e) {
37 | logDebug(e.toString());
38 | }
39 |
40 | if (mounted) setState(() => _isLoading = false);
41 | },
42 | child: _isLoading
43 | ? CircularProgressIndicator(backgroundColor: context.themeExtension.background)
44 | : Text(
45 | widget.text,
46 | style: context.textTheme.labelLarge!.copyWith(color: widget.textColor ?? context.colorScheme.onPrimary),
47 | textAlign: TextAlign.center,
48 | ),
49 | ),
50 | );
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/lib/src/screens/pipeline_logs/screen_pipeline_logs.dart:
--------------------------------------------------------------------------------
1 | part of pipeline_logs;
2 |
3 | class _PipelineLogsScreen extends StatelessWidget {
4 | const _PipelineLogsScreen(this.ctrl, this.parameters);
5 |
6 | final _PipelineLogsController ctrl;
7 | final _PipelineLogsParameters parameters;
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return AppPage(
12 | init: ctrl.init,
13 | title: 'Pipeline logs',
14 | notifier: ctrl.logs,
15 | padding: const EdgeInsets.only(left: 8),
16 | showScrollbar: true,
17 | actions: [IconButton(onPressed: ctrl.shareLogs, icon: Icon(DevOpsIcons.share))],
18 | builder: (logs) => switch (logs) {
19 | '' => const Padding(
20 | padding: EdgeInsets.only(top: 150),
21 | child: Text('No logs found', textAlign: TextAlign.center),
22 | ),
23 | _ => SingleChildScrollView(
24 | scrollDirection: Axis.horizontal,
25 | child: Column(
26 | crossAxisAlignment: CrossAxisAlignment.start,
27 | children: [
28 | if (logs!.length >= 28) ...[
29 | Text(
30 | DateTime.tryParse(logs.trim().substring(0, 28))?.toDate() ?? '',
31 | style: context.textTheme.titleSmall!.copyWith(fontWeight: FontWeight.normal),
32 | ),
33 | const SizedBox(height: 5),
34 | ],
35 | ...logs
36 | .split('\n')
37 | .map(ctrl.trimDate)
38 | .map(
39 | (l) => Text(
40 | l.replaceAll('##[section]', ''),
41 | style: context.textTheme.bodySmall!.copyWith(
42 | fontWeight: FontWeight.normal,
43 | color: ctrl.logColor(l),
44 | ),
45 | ),
46 | ),
47 | ],
48 | ),
49 | ),
50 | },
51 | );
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/lib/src/screens/commit_detail/base_commit_detail.dart:
--------------------------------------------------------------------------------
1 | library commit_detail;
2 |
3 | import 'package:azure_devops/src/extensions/commit_extension.dart';
4 | import 'package:azure_devops/src/extensions/context_extension.dart';
5 | import 'package:azure_devops/src/extensions/datetime_extension.dart';
6 | import 'package:azure_devops/src/mixins/share_mixin.dart';
7 | import 'package:azure_devops/src/models/commit.dart';
8 | import 'package:azure_devops/src/models/commit_detail.dart';
9 | import 'package:azure_devops/src/router/router.dart';
10 | import 'package:azure_devops/src/services/azure_api_service.dart';
11 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
12 | import 'package:azure_devops/src/widgets/app_base_page.dart';
13 | import 'package:azure_devops/src/widgets/app_page.dart';
14 | import 'package:azure_devops/src/widgets/changed_files.dart';
15 | import 'package:azure_devops/src/widgets/commit_list_tile.dart';
16 | import 'package:azure_devops/src/widgets/member_avatar.dart';
17 | import 'package:azure_devops/src/widgets/project_and_repo_chips.dart';
18 | import 'package:azure_devops/src/widgets/text_title_description.dart';
19 | import 'package:flutter/material.dart';
20 | import 'package:path/path.dart';
21 |
22 | part 'components_commit_detail.dart';
23 | part 'controller_commit_detail.dart';
24 | part 'parameters_commit_detail.dart';
25 | part 'screen_commit_detail.dart';
26 |
27 | class CommitDetailPage extends StatelessWidget {
28 | const CommitDetailPage();
29 |
30 | static const _smartphoneParameters = _CommitDetailParameters();
31 | static const _tabletParameters = _CommitDetailParameters();
32 |
33 | @override
34 | Widget build(BuildContext context) {
35 | final args = AppRouter.getCommitDetailArgs(context);
36 | return AppBasePage(
37 | initState: () => _CommitDetailController._(args, context.api),
38 | smartphone: (ctrl) => _CommitDetailScreen(ctrl, _smartphoneParameters),
39 | tablet: (ctrl) => _CommitDetailScreen(ctrl, _tabletParameters),
40 | );
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/lib/firebase_options.dart:
--------------------------------------------------------------------------------
1 | import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
2 | import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb;
3 |
4 | class DefaultFirebaseOptions {
5 | static FirebaseOptions get currentPlatform {
6 | if (kIsWeb) {
7 | throw UnsupportedError(
8 | 'DefaultFirebaseOptions have not been configured for web - '
9 | 'you can reconfigure this by running the FlutterFire CLI again.',
10 | );
11 | }
12 | switch (defaultTargetPlatform) {
13 | case TargetPlatform.android:
14 | return android;
15 | case TargetPlatform.iOS:
16 | return ios;
17 | case TargetPlatform.macOS:
18 | throw UnsupportedError(
19 | 'DefaultFirebaseOptions have not been configured for macos - '
20 | 'you can reconfigure this by running the FlutterFire CLI again.',
21 | );
22 | case TargetPlatform.windows:
23 | throw UnsupportedError(
24 | 'DefaultFirebaseOptions have not been configured for windows - '
25 | 'you can reconfigure this by running the FlutterFire CLI again.',
26 | );
27 | case TargetPlatform.linux:
28 | throw UnsupportedError(
29 | 'DefaultFirebaseOptions have not been configured for linux - '
30 | 'you can reconfigure this by running the FlutterFire CLI again.',
31 | );
32 | default:
33 | throw UnsupportedError('DefaultFirebaseOptions are not supported for this platform.');
34 | }
35 | }
36 |
37 | static const FirebaseOptions android = FirebaseOptions(
38 | apiKey: 'apiKey',
39 | appId: 'appId',
40 | messagingSenderId: 'messagingSenderId',
41 | projectId: 'projectId',
42 | storageBucket: 'storageBucket',
43 | );
44 |
45 | static const FirebaseOptions ios = FirebaseOptions(
46 | apiKey: 'apiKey',
47 | appId: 'appId',
48 | messagingSenderId: 'messagingSenderId',
49 | projectId: 'projectId',
50 | storageBucket: 'storageBucket',
51 | iosClientId: 'iosClientId',
52 | iosBundleId: 'iosBundleId',
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/test/commits_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/screens/commits/base_commits.dart';
2 | import 'package:azure_devops/src/services/ads_service.dart';
3 | import 'package:azure_devops/src/services/azure_api_service.dart';
4 | import 'package:azure_devops/src/services/storage_service.dart';
5 | import 'package:flutter/material.dart';
6 | import 'package:flutter_test/flutter_test.dart';
7 | import 'package:visibility_detector/visibility_detector.dart';
8 |
9 | import 'api_service_mock.dart';
10 |
11 | void main() {
12 | setUp(() => VisibilityDetectorController.instance.updateInterval = Duration.zero);
13 |
14 | TestWidgetsFlutterBinding.ensureInitialized();
15 |
16 | testWidgets('Page building test', (t) async {
17 | final app = MaterialApp(
18 | theme: mockTheme,
19 | home: AzureApiServiceWidget(
20 | api: AzureApiServiceMock(),
21 | child: AdsServiceWidget(
22 | ads: AdsServiceMock(),
23 | child: StorageServiceWidget(storage: StorageServiceMock(), child: CommitsPage()),
24 | ),
25 | ),
26 | );
27 |
28 | await t.pumpWidget(app);
29 |
30 | await t.pump();
31 |
32 | expect(find.byType(CommitsPage), findsOneWidget);
33 | });
34 |
35 | testWidgets('Commits are sorted by date descending', (t) async {
36 | final commitsPage = MaterialApp(
37 | theme: mockTheme,
38 | home: AzureApiServiceWidget(
39 | api: AzureApiServiceMock(),
40 | child: AdsServiceWidget(
41 | ads: AdsServiceMock(),
42 | child: StorageServiceWidget(storage: StorageServiceMock(), child: CommitsPage()),
43 | ),
44 | ),
45 | );
46 |
47 | await t.pumpWidget(commitsPage);
48 |
49 | await t.pump();
50 |
51 | final tiles = find.textContaining('Test User');
52 |
53 | // most recent commit
54 | expect((t.widget(tiles.at(0)) as Text).data, 'Test User 2');
55 |
56 | // queued pipeline
57 | expect((t.widget(tiles.at(1)) as Text).data, 'Test User 3');
58 |
59 | // least recent commit
60 | expect((t.widget(tiles.at(2)) as Text).data, 'Test User 1');
61 | });
62 | }
63 |
--------------------------------------------------------------------------------
/test/pipelines_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/screens/pipelines/base_pipelines.dart';
2 | import 'package:azure_devops/src/services/ads_service.dart';
3 | import 'package:azure_devops/src/services/azure_api_service.dart';
4 | import 'package:azure_devops/src/services/storage_service.dart';
5 | import 'package:flutter/material.dart';
6 | import 'package:flutter_test/flutter_test.dart';
7 | import 'package:visibility_detector/visibility_detector.dart';
8 |
9 | import 'api_service_mock.dart';
10 |
11 | void main() {
12 | setUp(() => VisibilityDetectorController.instance.updateInterval = Duration.zero);
13 |
14 | TestWidgetsFlutterBinding.ensureInitialized();
15 |
16 | testWidgets('Page building test', (t) async {
17 | final app = MaterialApp(
18 | theme: mockTheme,
19 | home: StorageServiceWidget(
20 | storage: StorageServiceMock(),
21 | child: AdsServiceWidget(
22 | ads: AdsServiceMock(),
23 | child: AzureApiServiceWidget(api: AzureApiServiceMock(), child: PipelinesPage()),
24 | ),
25 | ),
26 | );
27 |
28 | await t.pumpWidget(app);
29 |
30 | await t.pump();
31 |
32 | expect(find.byType(PipelinesPage), findsOneWidget);
33 | });
34 |
35 | testWidgets('Pipelines are sorted by status', (t) async {
36 | final pipelinesPage = MaterialApp(
37 | theme: mockTheme,
38 | home: StorageServiceWidget(
39 | storage: StorageServiceMock(),
40 | child: AdsServiceWidget(
41 | ads: AdsServiceMock(),
42 | child: AzureApiServiceWidget(api: AzureApiServiceMock(), child: PipelinesPage()),
43 | ),
44 | ),
45 | );
46 |
47 | await t.pumpWidget(pipelinesPage);
48 |
49 | await t.pump();
50 |
51 | final tiles = find.textContaining('Test User');
52 |
53 | // running pipeline
54 | expect((t.widget(tiles.at(0)) as Text).data, 'Test User 2');
55 |
56 | // queued pipeline
57 | expect((t.widget(tiles.at(1)) as Text).data, 'Test User 3');
58 |
59 | // completed pipeline
60 | expect((t.widget(tiles.at(2)) as Text).data, 'Test User 1');
61 | });
62 | }
63 |
--------------------------------------------------------------------------------
/lib/src/screens/file_detail/controller_file_detail.dart:
--------------------------------------------------------------------------------
1 | part of file_detail;
2 |
3 | class _FileDetailController with ShareMixin {
4 | _FileDetailController._(this.api, this.args);
5 |
6 | final AzureApiService api;
7 | final RepoDetailArgs args;
8 |
9 | final fileContent = ValueNotifier?>(null);
10 |
11 | Future init() async {
12 | final fileRes = await api.getFileDetail(
13 | projectName: args.projectName,
14 | repoName: args.repositoryName,
15 | path: args.filePath ?? '/',
16 | branch: args.branch,
17 | );
18 |
19 | fileContent.value = fileRes;
20 |
21 | // register all builtin languages
22 | for (final lang in builtinLanguages.entries) {
23 | highlight.registerLanguage(lang.value);
24 | }
25 | }
26 |
27 | void shareFile() {
28 | shareUrl(_fileUrl);
29 | }
30 |
31 | String get _fileUrl =>
32 | '${api.basePath}/${args.projectName}/_git/${args.repositoryName}?path=${args.filePath}&version=GB${args.branch}';
33 | }
34 |
35 | /// Map file extension to highlighting package language id
36 | const Map languageExtensions = {
37 | 'as': 'actionscript',
38 | 'adoc': 'asciidoc',
39 | 'ahk': 'autohotkey',
40 | 'au3': 'autoit',
41 | 'x++': 'axapta',
42 | 'sh': 'bash',
43 | 'bas': 'basic',
44 | 'bf': 'brainfuck',
45 | 'capnp': 'capnproto',
46 | 'coffee': 'coffeescript',
47 | 'cs': 'csharp',
48 | 'zone': 'dns',
49 | 'bat': 'dos',
50 | 'xlsx': 'excel',
51 | 'f90': 'fortran',
52 | 'fs': 'fsharp',
53 | 'gms': 'gams',
54 | 'dat': 'gauss',
55 | 'feature': 'gherkin',
56 | 'm': 'objectivec',
57 | 'ml': 'ocaml',
58 | 'scad': 'openscad',
59 | 'ps1': 'powershell',
60 | 'pde': 'processing',
61 | 'proto': 'protobuf',
62 | 'pp': 'puppet',
63 | 'pb': 'purebasic',
64 | 'py': 'python',
65 | 're': 'reasonml',
66 | 'rfx': 'roboconf',
67 | 'rsc': 'routeros',
68 | 'rules': 'ruleslanguage',
69 | 'rs': 'rust',
70 | 'st': 'smalltalk',
71 | 'do': 'stata',
72 | 'step': 'step21',
73 | 'ts': 'taggerscript',
74 | 'asm': 'x86asm',
75 | 'xq': 'xquery',
76 | 'zep': 'zephir',
77 | };
78 |
--------------------------------------------------------------------------------
/lib/src/models/commit_detail.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:http/http.dart';
4 |
5 | class CommitChanges {
6 | CommitChanges({required this.changes});
7 |
8 | factory CommitChanges.fromResponse(Response res) =>
9 | CommitChanges.fromJson(jsonDecode(res.body) as Map);
10 |
11 | factory CommitChanges.fromJson(Map json) => CommitChanges(
12 | changes: json['changes'] == null
13 | ? []
14 | : List.from((json['changes'] as List).map((e) => Change.fromJson(e as Map))),
15 | );
16 |
17 | final List? changes;
18 |
19 | @override
20 | String toString() => 'CommitDetail(changes: $changes)';
21 | }
22 |
23 | class Change {
24 | Change({required this.item, required this.changeType});
25 |
26 | factory Change.fromJson(Map json) =>
27 | Change(item: Item.fromJson(json['item'] as Map), changeType: json['changeType'] as String?);
28 |
29 | final Item? item;
30 | final String? changeType;
31 |
32 | @override
33 | String toString() => 'Change(item: $item, changeType: $changeType)';
34 | }
35 |
36 | class Item {
37 | Item({
38 | required this.objectId,
39 | required this.originalObjectId,
40 | required this.gitObjectType,
41 | required this.commitId,
42 | required this.path,
43 | required this.url,
44 | });
45 |
46 | factory Item.fromJson(Map json) => Item(
47 | objectId: json['objectId'] as String?,
48 | originalObjectId: json['originalObjectId'] as String?,
49 | gitObjectType: json['gitObjectType'] as String?,
50 | commitId: json['commitId'] as String?,
51 | path: json['path'] as String?,
52 | url: json['url'] as String?,
53 | );
54 |
55 | final String? objectId;
56 | final String? originalObjectId;
57 | final String? gitObjectType;
58 | final String? commitId;
59 | final String? path;
60 | final String? url;
61 |
62 | @override
63 | String toString() {
64 | return 'Item(objectId: $objectId, originalObjectId: $originalObjectId, gitObjectType: $gitObjectType, commitId: $commitId, path: $path, url: $url)';
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/lib/src/screens/profile/base_profile.dart:
--------------------------------------------------------------------------------
1 | library profile;
2 |
3 | import 'dart:async';
4 |
5 | import 'package:azure_devops/src/extensions/commit_extension.dart';
6 | import 'package:azure_devops/src/extensions/context_extension.dart';
7 | import 'package:azure_devops/src/extensions/datetime_extension.dart';
8 | import 'package:azure_devops/src/mixins/ads_mixin.dart';
9 | import 'package:azure_devops/src/mixins/filter_mixin.dart';
10 | import 'package:azure_devops/src/models/commit.dart';
11 | import 'package:azure_devops/src/models/user.dart';
12 | import 'package:azure_devops/src/models/work_items.dart';
13 | import 'package:azure_devops/src/router/router.dart';
14 | import 'package:azure_devops/src/services/ads_service.dart';
15 | import 'package:azure_devops/src/services/azure_api_service.dart';
16 | import 'package:azure_devops/src/services/storage_service.dart';
17 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
18 | import 'package:azure_devops/src/theme/theme.dart';
19 | import 'package:azure_devops/src/widgets/ad_widget.dart';
20 | import 'package:azure_devops/src/widgets/app_base_page.dart';
21 | import 'package:azure_devops/src/widgets/app_page.dart';
22 | import 'package:azure_devops/src/widgets/commit_list_tile.dart';
23 | import 'package:azure_devops/src/widgets/member_avatar.dart';
24 | import 'package:azure_devops/src/widgets/section_header.dart';
25 | import 'package:collection/collection.dart';
26 | import 'package:flutter/material.dart';
27 |
28 | part 'components_profile.dart';
29 | part 'controller_profile.dart';
30 | part 'parameters_profile.dart';
31 | part 'screen_profile.dart';
32 |
33 | class ProfilePage extends StatelessWidget {
34 | const ProfilePage();
35 |
36 | static const _smartphoneParameters = _ProfileParameters();
37 | static const _tabletParameters = _ProfileParameters();
38 |
39 | @override
40 | Widget build(BuildContext context) {
41 | return AppBasePage(
42 | initState: () => _ProfileController._(context.api, context.storage, context.ads),
43 | smartphone: (ctrl) => _ProfileScreen(ctrl, _smartphoneParameters),
44 | tablet: (ctrl) => _ProfileScreen(ctrl, _tabletParameters),
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Az DevOps
2 |
3 | Unofficial [Azure DevOps](https://azure.microsoft.com/en-us/products/devops) mobile app.
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Features:
16 | - Login with Microsoft or with your Personal Access Token
17 | - See and manage your pipelines
18 | - See your commits
19 | - See and manage your work items
20 | - See and manage your pull requests
21 |
22 | Follow [this guide](https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows) to generate a new Personal Acces Token. Be sure to select 'All accessible organizations', otherwise you will have to manually input the organization name.
23 |
24 | # App Downloads
25 |
26 | To try the app you can download it from your favorite app store:
27 | * [Google Play](https://play.google.com/store/apps/details?id=io.purplesoft.azuredevops)
28 | * [Apple App Store](https://apps.apple.com/us/app/az-devops/id1666994628)
29 |
30 | # Installation from source
31 |
32 | If you're new to Flutter the first thing you'll need is to follow the [setup instructions](https://flutter.dev/docs/get-started/install).
33 |
34 | Once Flutter is setup:
35 | * `flutter channel stable`
36 | * `flutter run`
37 |
38 | # About Purplesoft
39 | We are a team of young digital creators, specializing in computer science, UX/UI design
40 | and digital marketing.
41 |
42 | Visit [our site](https://purplesoft.io/) for more info.
43 |
44 | # License
45 |
46 | This application is released under the [MIT license](LICENSE). You can use the code for any purpose, including commercial projects.
47 |
48 | [](https://opensource.org/licenses/MIT)
--------------------------------------------------------------------------------
/test/choose_projects_test.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/router/router.dart';
2 | import 'package:azure_devops/src/screens/choose_projects/base_choose_projects.dart';
3 | import 'package:azure_devops/src/services/azure_api_service.dart';
4 | import 'package:azure_devops/src/services/storage_service.dart';
5 | import 'package:flutter/material.dart';
6 | import 'package:flutter_test/flutter_test.dart';
7 |
8 | import 'api_service_mock.dart';
9 |
10 | void main() {
11 | TestWidgetsFlutterBinding.ensureInitialized();
12 |
13 | testWidgets('Page building test', (t) async {
14 | final app = AzureApiServiceWidget(
15 | api: AzureApiServiceMock(),
16 | child: StorageServiceWidget(
17 | storage: StorageServiceMock(),
18 | child: MaterialApp(
19 | navigatorKey: AppRouter.navigatorKey,
20 | theme: mockTheme,
21 | onGenerateRoute: (settings) =>
22 | MaterialPageRoute(builder: (ctx) => ChooseProjectsPage(), settings: RouteSettings(arguments: false)),
23 | ),
24 | ),
25 | );
26 |
27 | await t.pumpWidget(app);
28 |
29 | await t.pump();
30 |
31 | expect(find.byType(ChooseProjectsPage), findsOneWidget);
32 | });
33 | testWidgets('All projects are selected by default', (t) async {
34 | final app = AzureApiServiceWidget(
35 | api: AzureApiServiceMock(),
36 | child: StorageServiceWidget(
37 | storage: StorageServiceMock(),
38 | child: MaterialApp(
39 | navigatorKey: AppRouter.navigatorKey,
40 | theme: mockTheme,
41 | onGenerateRoute: (settings) =>
42 | MaterialPageRoute(builder: (ctx) => ChooseProjectsPage(), settings: RouteSettings(arguments: false)),
43 | ),
44 | ),
45 | );
46 |
47 | await t.pumpWidget(app);
48 |
49 | final pageTitle = find.textContaining('Choose projects');
50 | expect(pageTitle, findsOneWidget);
51 |
52 | await t.pumpAndSettle();
53 |
54 | final checkboxFinder = find.byType(Checkbox);
55 | expect(checkboxFinder, findsWidgets);
56 |
57 | final checkboxes = t.widgetList(checkboxFinder);
58 | for (final checkbox in checkboxes) {
59 | expect(checkbox.value, isTrue);
60 | }
61 | });
62 | }
63 |
--------------------------------------------------------------------------------
/lib/src/screens/board_detail/base_board_detail.dart:
--------------------------------------------------------------------------------
1 | library board_detail;
2 |
3 | import 'package:azure_devops/src/extensions/context_extension.dart';
4 | import 'package:azure_devops/src/mixins/ads_mixin.dart';
5 | import 'package:azure_devops/src/mixins/api_error_mixin.dart';
6 | import 'package:azure_devops/src/mixins/filter_mixin.dart';
7 | import 'package:azure_devops/src/models/board.dart';
8 | import 'package:azure_devops/src/models/processes.dart';
9 | import 'package:azure_devops/src/models/user.dart';
10 | import 'package:azure_devops/src/models/work_items.dart';
11 | import 'package:azure_devops/src/router/router.dart';
12 | import 'package:azure_devops/src/services/ads_service.dart';
13 | import 'package:azure_devops/src/services/azure_api_service.dart';
14 | import 'package:azure_devops/src/services/overlay_service.dart';
15 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
16 | import 'package:azure_devops/src/widgets/app_base_page.dart';
17 | import 'package:azure_devops/src/widgets/app_page.dart';
18 | import 'package:azure_devops/src/widgets/board_widget.dart';
19 | import 'package:azure_devops/src/widgets/filter_menu.dart';
20 | import 'package:azure_devops/src/widgets/navigation_button.dart';
21 | import 'package:azure_devops/src/widgets/popup_menu.dart';
22 | import 'package:azure_devops/src/widgets/search_field.dart';
23 | import 'package:collection/collection.dart';
24 | import 'package:flutter/material.dart';
25 |
26 | part 'components_board_detail.dart';
27 | part 'controller_board_detail.dart';
28 | part 'screen_board_detail.dart';
29 |
30 | typedef _BoardDetailParameters = ();
31 |
32 | class BoardDetailPage extends StatelessWidget {
33 | const BoardDetailPage();
34 |
35 | static const _BoardDetailParameters _smartphoneParameters = ();
36 | static const _BoardDetailParameters _tabletParameters = ();
37 |
38 | @override
39 | Widget build(BuildContext context) {
40 | final args = AppRouter.getBoardDetailArgs(context);
41 | return AppBasePage(
42 | initState: () => _BoardDetailController._(context.api, args, context.ads),
43 | smartphone: (ctrl) => _BoardDetailScreen(ctrl, _smartphoneParameters),
44 | tablet: (ctrl) => _BoardDetailScreen(ctrl, _tabletParameters),
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/lib/src/models/organization.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:flutter/foundation.dart';
4 | import 'package:http/http.dart';
5 |
6 | class GetOrganizationsResponse {
7 | GetOrganizationsResponse({required this.count, required this.organizations});
8 |
9 | factory GetOrganizationsResponse.fromJson(Map json) => GetOrganizationsResponse(
10 | count: json['count'] as int?,
11 | organizations: json['value'] == null
12 | ? []
13 | : List.from(
14 | (json['value'] as List).map((x) => Organization.fromJson(x as Map)),
15 | ),
16 | );
17 |
18 | static List fromResponse(Response res) =>
19 | GetOrganizationsResponse.fromJson(jsonDecode(res.body) as Map).organizations ?? [];
20 |
21 | final int? count;
22 | final List? organizations;
23 | }
24 |
25 | class Organization {
26 | Organization({
27 | required this.accountId,
28 | required this.accountUri,
29 | required this.accountName,
30 | required this.properties,
31 | });
32 |
33 | factory Organization.fromJson(Map json) => Organization(
34 | accountId: json['accountId'] as String?,
35 | accountUri: json['accountUri'] as String?,
36 | accountName: json['accountName'] as String?,
37 | properties: json['properties'] as Map,
38 | );
39 |
40 | final String? accountId;
41 | final String? accountUri;
42 | final String? accountName;
43 | final Map? properties;
44 |
45 | @override
46 | String toString() {
47 | return 'Organization(accountId: $accountId, accountUri: $accountUri, accountName: $accountName, properties: $properties)';
48 | }
49 |
50 | @override
51 | bool operator ==(Object other) {
52 | if (identical(this, other)) return true;
53 |
54 | return other is Organization &&
55 | other.accountId == accountId &&
56 | other.accountUri == accountUri &&
57 | other.accountName == accountName &&
58 | mapEquals(other.properties, properties);
59 | }
60 |
61 | @override
62 | int get hashCode {
63 | return accountId.hashCode ^ accountUri.hashCode ^ accountName.hashCode ^ properties.hashCode;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/lib/src/screens/pull_requests/base_pull_requests.dart:
--------------------------------------------------------------------------------
1 | library pull_requests;
2 |
3 | import 'package:azure_devops/src/extensions/context_extension.dart';
4 | import 'package:azure_devops/src/mixins/ads_mixin.dart';
5 | import 'package:azure_devops/src/mixins/api_error_mixin.dart';
6 | import 'package:azure_devops/src/mixins/filter_mixin.dart';
7 | import 'package:azure_devops/src/models/project.dart';
8 | import 'package:azure_devops/src/models/pull_request.dart';
9 | import 'package:azure_devops/src/models/user.dart';
10 | import 'package:azure_devops/src/router/router.dart';
11 | import 'package:azure_devops/src/services/ads_service.dart';
12 | import 'package:azure_devops/src/services/azure_api_service.dart';
13 | import 'package:azure_devops/src/services/filters_service.dart';
14 | import 'package:azure_devops/src/services/overlay_service.dart';
15 | import 'package:azure_devops/src/services/storage_service.dart';
16 | import 'package:azure_devops/src/widgets/ad_widget.dart';
17 | import 'package:azure_devops/src/widgets/app_base_page.dart';
18 | import 'package:azure_devops/src/widgets/app_page.dart';
19 | import 'package:azure_devops/src/widgets/filter_menu.dart';
20 | import 'package:azure_devops/src/widgets/pull_request_list_tile.dart';
21 | import 'package:azure_devops/src/widgets/search_field.dart';
22 | import 'package:azure_devops/src/widgets/shortcut_label.dart';
23 | import 'package:flutter/material.dart';
24 | import 'package:http/http.dart';
25 |
26 | part 'components_pull_requests.dart';
27 | part 'controller_pull_requests.dart';
28 | part 'parameters_pull_requests.dart';
29 | part 'screen_pull_requests.dart';
30 |
31 | class PullRequestsPage extends StatelessWidget {
32 | const PullRequestsPage();
33 |
34 | static const _smartphoneParameters = _PullRequestsParameters();
35 | static const _tabletParameters = _PullRequestsParameters();
36 |
37 | @override
38 | Widget build(BuildContext context) {
39 | final args = AppRouter.getPullRequestsArgs(context);
40 | return AppBasePage(
41 | initState: () => _PullRequestsController._(context.api, context.storage, args, context.ads),
42 | smartphone: (ctrl) => _PullRequestsScreen(ctrl, _smartphoneParameters),
43 | tablet: (ctrl) => _PullRequestsScreen(ctrl, _tabletParameters),
44 | );
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/lib/src/screens/saved_queries/controller_saved_queries.dart:
--------------------------------------------------------------------------------
1 | part of saved_queries;
2 |
3 | class _SavedQueriesController with AdsMixin {
4 | _SavedQueriesController._(this.args, this.api, this.ads);
5 |
6 | final SavedQueriesArgs args;
7 | final AzureApiService api;
8 | final AdsService ads;
9 |
10 | final savedQueries = ValueNotifier?>(null);
11 |
12 | Future init() async {
13 | final res = await api.getProjectSavedQuery(projectName: args.project, queryId: args.queryId);
14 | savedQueries.value = res;
15 | }
16 |
17 | void goToQuery(ChildQuery query) {
18 | if (!query.isFolder) {
19 | if (query.queryType != 'flat') {
20 | return OverlayService.snackbar('Only flat list queries are supported', isError: true);
21 | }
22 |
23 | AppRouter.goToWorkItems(args: (project: null, shortcut: null, savedQuery: query));
24 | return;
25 | }
26 |
27 | AppRouter.goToSavedQueries(args: (project: args.project, path: query.path, queryId: query.id));
28 | }
29 |
30 | Future renameQuery(ChildQuery query) async {
31 | final queryName = await OverlayService.formBottomsheet(
32 | title: 'Rename query',
33 | label: 'Name',
34 | initialValue: query.name,
35 | );
36 | if (queryName == null) return;
37 |
38 | final res = await api.renameSavedQuery(projectName: args.project, queryId: query.id, name: queryName);
39 |
40 | if (res.isError) {
41 | return OverlayService.error('Error', description: 'Query not renamed');
42 | }
43 |
44 | await showInterstitialAd(ads, onDismiss: () => OverlayService.snackbar('Query successfully renamed'));
45 |
46 | await init();
47 | }
48 |
49 | Future deleteQuery(ChildQuery query) async {
50 | final confirm = await OverlayService.confirm('Attention', description: 'Do you really want to delete this query?');
51 | if (!confirm) return;
52 |
53 | final res = await api.deleteSavedQuery(projectName: args.project, queryId: query.id);
54 |
55 | if (res.isError) {
56 | return OverlayService.error('Error', description: 'Query not deleted');
57 | }
58 |
59 | await showInterstitialAd(ads, onDismiss: () => OverlayService.snackbar('Query successfully deleted'));
60 |
61 | await init();
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/lib/src/services/msal_service.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/mixins/logger_mixin.dart';
2 | import 'package:msal_auth/msal_auth.dart';
3 |
4 | class MsalService with AppLogger {
5 | factory MsalService() {
6 | return instance ??= MsalService._();
7 | }
8 |
9 | MsalService._();
10 |
11 | static MsalService? instance;
12 |
13 | static const _scopes = ['499b84ac-1321-427f-aa17-267ca6975798/user_impersonation'];
14 |
15 | SingleAccountPca? _pca;
16 |
17 | void dispose() {
18 | instance = null;
19 | }
20 |
21 | Future init() async {
22 | setTag('MsalService');
23 |
24 | const msalClientId = String.fromEnvironment('MSAL_CLIENT_ID');
25 | const msalRedirectUri = String.fromEnvironment('MSAL_REDIRECT_URI');
26 |
27 | _pca = await SingleAccountPca.create(
28 | clientId: msalClientId,
29 | androidConfig: AndroidConfig(configFilePath: 'assets/msal_config.json', redirectUri: msalRedirectUri),
30 | appleConfig: AppleConfig(),
31 | );
32 | }
33 |
34 | Future logout() async {
35 | try {
36 | if (_pca == null) await init();
37 |
38 | await _pca!.signOut();
39 | } catch (_) {
40 | // ignore
41 | }
42 | }
43 |
44 | Future login({String? authority}) async {
45 | try {
46 | if (_pca == null) await init();
47 |
48 | final token = await _pca!.acquireToken(scopes: _scopes, prompt: Prompt.selectAccount, authority: authority);
49 | return LoginResponse(accessToken: token.accessToken, tenantId: token.tenantId ?? '');
50 | } on MsalUserCancelException catch (_) {
51 | return null;
52 | } on MsalException catch (e, s) {
53 | logError(e, s);
54 | return null;
55 | }
56 | }
57 |
58 | Future loginSilently({String? authority}) async {
59 | try {
60 | if (_pca == null) await init();
61 |
62 | final token = await _pca!.acquireTokenSilent(scopes: _scopes, authority: authority);
63 | return token.accessToken;
64 | } on MsalException catch (e, s) {
65 | logError(e, s);
66 | return null;
67 | }
68 | }
69 | }
70 |
71 | class LoginResponse {
72 | LoginResponse({required this.accessToken, required this.tenantId});
73 |
74 | final String accessToken;
75 | final String tenantId;
76 | }
77 |
--------------------------------------------------------------------------------
/lib/src/screens/project_boards/controller_project_boards.dart:
--------------------------------------------------------------------------------
1 | part of project_boards;
2 |
3 | class _ProjectBoardsController {
4 | _ProjectBoardsController._(this.api, this.projectName);
5 |
6 | final AzureApiService api;
7 | final String projectName;
8 |
9 | final projectBoards = ValueNotifier?>?>(null);
10 |
11 | Future init() async {
12 | await api.getWorkItemTypes();
13 | final boardsRes = await _getBoards();
14 | final sprintsRes = await _getSprints();
15 |
16 | if (boardsRes.isError) {
17 | projectBoards.value = ApiResponse.error(boardsRes.errorResponse);
18 | return;
19 | }
20 |
21 | if (sprintsRes.isError) {
22 | OverlayService.snackbar('Error getting sprints', isError: true);
23 | }
24 |
25 | final teamBoards = boardsRes.data!;
26 | final teamSprints = sprintsRes.data;
27 |
28 | final res = {};
29 |
30 | for (final entry in teamBoards.entries) {
31 | final team = entry.key;
32 | final boards = entry.value;
33 | final sprints = teamSprints?[team] ?? [];
34 |
35 | res[team] = _BoardsAndSprints(boards: boards, sprints: sprints);
36 | }
37 |
38 | projectBoards.value = ApiResponse.ok(res);
39 | }
40 |
41 | Future>>> _getBoards() async {
42 | final boardsRes = await api.getProjectBoards(projectName: projectName);
43 | return boardsRes;
44 | }
45 |
46 | Future>>> _getSprints() async {
47 | final sprintsRes = await api.getProjectSprints(projectName: projectName);
48 | return sprintsRes;
49 | }
50 |
51 | void goToBoardDetail(Team team, Board board) {
52 | AppRouter.goToBoardDetail(
53 | args: (project: projectName, teamId: team.id, boardName: board.name, backlogId: board.backlogId!),
54 | );
55 | }
56 |
57 | void goToSprintDetail(Team team, Sprint sprint) {
58 | AppRouter.goToSprintDetail(
59 | args: (project: projectName, teamId: team.id, sprintId: sprint.id, sprintName: sprint.name),
60 | );
61 | }
62 | }
63 |
64 | class _BoardsAndSprints {
65 | _BoardsAndSprints({required this.boards, required this.sprints});
66 |
67 | final List boards;
68 | final List sprints;
69 | }
70 |
--------------------------------------------------------------------------------
/lib/src/screens/file_diff/base_file_diff.dart:
--------------------------------------------------------------------------------
1 | library file_diff;
2 |
3 | import 'dart:math';
4 |
5 | import 'package:azure_devops/src/extensions/commit_extension.dart';
6 | import 'package:azure_devops/src/extensions/context_extension.dart';
7 | import 'package:azure_devops/src/mixins/ads_mixin.dart';
8 | import 'package:azure_devops/src/mixins/logger_mixin.dart';
9 | import 'package:azure_devops/src/mixins/pull_request_mixin.dart';
10 | import 'package:azure_devops/src/mixins/share_mixin.dart';
11 | import 'package:azure_devops/src/models/file_diff.dart';
12 | import 'package:azure_devops/src/models/pull_request_with_details.dart';
13 | import 'package:azure_devops/src/router/router.dart';
14 | import 'package:azure_devops/src/services/ads_service.dart';
15 | import 'package:azure_devops/src/services/azure_api_service.dart';
16 | import 'package:azure_devops/src/services/overlay_service.dart';
17 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
18 | import 'package:azure_devops/src/widgets/add_comment_field.dart';
19 | import 'package:azure_devops/src/widgets/app_base_page.dart';
20 | import 'package:azure_devops/src/widgets/app_page.dart';
21 | import 'package:azure_devops/src/widgets/member_avatar.dart';
22 | import 'package:azure_devops/src/widgets/popup_menu.dart';
23 | import 'package:azure_devops/src/widgets/pull_request_comment_card.dart';
24 | import 'package:collection/collection.dart';
25 | import 'package:flutter/foundation.dart';
26 | import 'package:flutter/material.dart';
27 | import 'package:html_editor_enhanced/html_editor.dart';
28 |
29 | part 'components_file_diff.dart';
30 | part 'controller_file_diff.dart';
31 | part 'parameters_file_diff.dart';
32 | part 'screen_file_diff.dart';
33 |
34 | class FileDiffPage extends StatelessWidget {
35 | const FileDiffPage();
36 |
37 | static const _smartphoneParameters = _FileDiffParameters();
38 | static const _tabletParameters = _FileDiffParameters();
39 |
40 | @override
41 | Widget build(BuildContext context) {
42 | final args = AppRouter.getCommitDiffArgs(context);
43 | return AppBasePage(
44 | initState: () => _FileDiffController._(context.api, args, context.ads),
45 | smartphone: (ctrl) => _FileDiffScreen(ctrl, _smartphoneParameters),
46 | tablet: (ctrl) => _FileDiffScreen(ctrl, _tabletParameters),
47 | );
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/lib/src/screens/project_detail/base_project_detail.dart:
--------------------------------------------------------------------------------
1 | library project_detail;
2 |
3 | import 'package:azure_devops/src/extensions/context_extension.dart';
4 | import 'package:azure_devops/src/mixins/api_error_mixin.dart';
5 | import 'package:azure_devops/src/models/project.dart';
6 | import 'package:azure_devops/src/models/project_languages.dart';
7 | import 'package:azure_devops/src/models/repository.dart';
8 | import 'package:azure_devops/src/models/saved_query.dart';
9 | import 'package:azure_devops/src/models/team.dart';
10 | import 'package:azure_devops/src/models/team_member.dart';
11 | import 'package:azure_devops/src/router/router.dart';
12 | import 'package:azure_devops/src/services/azure_api_service.dart';
13 | import 'package:azure_devops/src/services/overlay_service.dart';
14 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
15 | import 'package:azure_devops/src/widgets/app_base_page.dart';
16 | import 'package:azure_devops/src/widgets/app_page.dart';
17 | import 'package:azure_devops/src/widgets/member_avatar.dart';
18 | import 'package:azure_devops/src/widgets/navigation_button.dart';
19 | import 'package:azure_devops/src/widgets/section_header.dart';
20 | import 'package:azure_devops/src/widgets/work_card.dart';
21 | import 'package:cached_network_image/cached_network_image.dart';
22 | import 'package:collection/collection.dart';
23 | import 'package:flutter/material.dart';
24 | import 'package:http/http.dart';
25 |
26 | part 'components_project_detail.dart';
27 | part 'controller_project_detail.dart';
28 | part 'parameters_project_detail.dart';
29 | part 'screen_project_detail.dart';
30 |
31 | class ProjectDetailPage extends StatelessWidget {
32 | const ProjectDetailPage();
33 |
34 | static const _smartphoneParameters = _ProjectDetailParameters(gridItemAspectRatio: 1.4, memberAvatarSize: 50);
35 | static const _tabletParameters = _ProjectDetailParameters(gridItemAspectRatio: 2.4, memberAvatarSize: 75);
36 |
37 | @override
38 | Widget build(BuildContext context) {
39 | final project = AppRouter.getProjectDetailArgs(context);
40 | return AppBasePage(
41 | initState: () => _ProjectDetailController._(context.api, project),
42 | smartphone: (ctrl) => _ProjectDetailScreen(ctrl, _smartphoneParameters),
43 | tablet: (ctrl) => _ProjectDetailScreen(ctrl, _tabletParameters),
44 | );
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/lib/src/mixins/logger_mixin.dart:
--------------------------------------------------------------------------------
1 | import 'dart:developer';
2 |
3 | import 'package:azure_devops/main.dart';
4 | import 'package:firebase_analytics/firebase_analytics.dart';
5 | import 'package:flutter/foundation.dart';
6 | import 'package:sentry_flutter/sentry_flutter.dart';
7 |
8 | mixin AppLogger {
9 | String? _tag;
10 |
11 | // ignore: use_setters_to_change_properties
12 | void setTag(String tag) {
13 | _tag = tag;
14 | }
15 |
16 | /// Logs only if [kDebugMode]
17 | void logDebug(String msg) {
18 | if (kDebugMode) log(msg, name: _tag ?? '');
19 | }
20 |
21 | /// Logs on Sentry with level info only if ![kDebugMode]
22 | void logInfo(String msg) {
23 | if (kDebugMode) return;
24 |
25 | Sentry.captureMessage(msg);
26 | }
27 |
28 | /// Logs exception on Sentry with level error only if ![kDebugMode]
29 | void logError(Object? exception, Object stacktrace) {
30 | if (kDebugMode) {
31 | logDebug('Error: $exception');
32 | return;
33 | }
34 |
35 | Sentry.captureException(exception, stackTrace: stacktrace);
36 | }
37 |
38 | /// Logs message on Sentry with level error only if ![kDebugMode]
39 | void logErrorMessage(String message) {
40 | if (kDebugMode) {
41 | logDebug('Error: $message');
42 | return;
43 | }
44 |
45 | final tagStr = (_tag ?? '').isNotEmpty ? '[$_tag] ' : '';
46 | final errorMessage = '${tagStr}Error: $message';
47 | Sentry.captureMessage(errorMessage, level: SentryLevel.error);
48 | }
49 |
50 | /// Logs on Firebase Analytics only if [useFirebase] is true
51 | void logAnalytics(String name, Map parameters) {
52 | if (!useFirebase) return;
53 |
54 | const prefix = 'az_';
55 | final prefixedName = name.startsWith('az_') ? name : '$prefix$name';
56 | final prefixedParameters = {};
57 |
58 | for (final entry in parameters.entries) {
59 | final oldKey = entry.key;
60 | final prefixedKey = oldKey.startsWith(prefix) ? oldKey : '$prefix$oldKey';
61 |
62 | final oldValue = entry.value;
63 | final value = (oldValue is String || oldValue is num) ? oldValue : oldValue.toString();
64 | prefixedParameters.putIfAbsent(prefixedKey, () => value ?? '');
65 | }
66 |
67 | FirebaseAnalytics.instance.logEvent(name: prefixedName, parameters: prefixedParameters);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/lib/src/extensions/approval_extension.dart:
--------------------------------------------------------------------------------
1 | import 'dart:math';
2 |
3 | import 'package:azure_devops/src/extensions/context_extension.dart';
4 | import 'package:azure_devops/src/extensions/datetime_extension.dart';
5 | import 'package:azure_devops/src/extensions/string_extension.dart';
6 | import 'package:azure_devops/src/models/pipeline_approvals.dart';
7 | import 'package:azure_devops/src/router/router.dart';
8 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
9 | import 'package:flutter/material.dart';
10 |
11 | extension ApprovalExt on Approval {
12 | String get executionOrderDescription {
13 | if (minRequiredApprovers < steps.length) {
14 | return 'At least $minRequiredApprovers approvers must approve';
15 | }
16 |
17 | return 'All approvers must approve${executionOrder == 'inSequence' ? ' in sequence' : ''}';
18 | }
19 |
20 | int getLastStepTimestamp() => steps.fold(
21 | DateTime(1900).millisecondsSinceEpoch,
22 | (prev, step) => max(prev, (step.lastModifiedOn ?? DateTime.now()).millisecondsSinceEpoch),
23 | );
24 |
25 | bool get isPending => status == 'pending';
26 | }
27 |
28 | extension StepExt on ApprovalStep {
29 | bool get isCompleted => ['approved', 'rejected'].contains(status);
30 |
31 | Icon get statusIcon {
32 | switch (status) {
33 | case 'approved':
34 | return Icon(DevOpsIcons.success, color: Colors.green);
35 | case 'rejected':
36 | return Icon(DevOpsIcons.failed, color: AppRouter.navigatorKey.currentContext!.colorScheme.error);
37 | case 'pending':
38 | return Icon(DevOpsIcons.queued, color: Colors.blue);
39 | case 'deferred':
40 | return Icon(DevOpsIcons.queued, color: Colors.blue);
41 | case 'timedOut':
42 | return Icon(DevOpsIcons.skipped, color: AppRouter.rootNavigator!.context.themeExtension.onBackground);
43 |
44 | default:
45 | return Icon(Icons.question_mark, color: Colors.transparent);
46 | }
47 | }
48 |
49 | String get statusDescription {
50 | final timeAgo = lastModifiedOn?.minutesAgo;
51 | final isNow = timeAgo == 'now';
52 | final isDeferred = status.toLowerCase() == 'deferred';
53 |
54 | return '${status.titleCase}${isDeferred ? ' to ${deferredTo?.toSimpleDate()}' : ''} $timeAgo${isNow ? '' : ' ago'}';
55 | }
56 |
57 | bool get isPending => status == 'pending';
58 | }
59 |
--------------------------------------------------------------------------------
/lib/src/models/amazon/amazon_item.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | class AmazonItem {
4 | AmazonItem({
5 | required this.id,
6 | required this.itemUrl,
7 | required this.title,
8 | required this.imageUrl,
9 | required this.isPrime,
10 | required this.originalPrice,
11 | required this.discount,
12 | required this.discountedPrice,
13 | required this.currency,
14 | });
15 |
16 | factory AmazonItem.fromJson(Map json) => AmazonItem(
17 | id: json['id'] as String? ?? '',
18 | itemUrl: json['itemUrl'] as String? ?? '',
19 | title: json['title'] as String? ?? '',
20 | imageUrl: json['imageUrl'] as String? ?? '',
21 | isPrime: json['isPrime'] as bool? ?? false,
22 | originalPrice: Price.fromJson(json['originalPrice'] as Map? ?? {}),
23 | discount: json['discount'] == null ? null : Discount.fromJson(json['discount'] as Map? ?? {}),
24 | discountedPrice: Price.fromJson(json['discountedPrice'] as Map? ?? {}),
25 | currency: json['currency'] as String? ?? '',
26 | );
27 |
28 | static List listFromJson(String str) => List.from(
29 | (json.decode(str) as List? ?? []).map((x) => AmazonItem.fromJson(x as Map? ?? {})),
30 | );
31 |
32 | final String id;
33 | final String itemUrl;
34 | final String title;
35 | final String imageUrl;
36 | final bool isPrime;
37 | final Price originalPrice;
38 | final Discount? discount;
39 | final Price discountedPrice;
40 | final String currency;
41 |
42 | @override
43 | bool operator ==(covariant AmazonItem other) {
44 | if (identical(this, other)) return true;
45 |
46 | return other.id == id;
47 | }
48 |
49 | @override
50 | int get hashCode {
51 | return id.hashCode;
52 | }
53 | }
54 |
55 | class Discount {
56 | Discount({required this.amount, required this.percentage});
57 |
58 | factory Discount.fromJson(Map json) =>
59 | Discount(amount: (json['amount'] as num?)?.toDouble() ?? 0, percentage: json['percentage'] as int? ?? 0);
60 |
61 | final double amount;
62 | final int percentage;
63 | }
64 |
65 | class Price {
66 | Price({required this.amount});
67 |
68 | factory Price.fromJson(Map json) => Price(amount: (json['amount'] as num?)?.toDouble() ?? 0);
69 |
70 | final double amount;
71 | }
72 |
--------------------------------------------------------------------------------
/lib/src/models/commits_tags.dart:
--------------------------------------------------------------------------------
1 | import 'dart:convert';
2 |
3 | import 'package:http/http.dart';
4 |
5 | class TagsResponse {
6 | TagsResponse({required this.data});
7 |
8 | factory TagsResponse.fromJson(Map json) =>
9 | TagsResponse(data: _TagsDataProvider.fromJson(json['dataProviders'] as Map));
10 |
11 | static TagsData? fromResponse(Response res) =>
12 | TagsResponse.fromJson(json.decode(res.body) as Map).data.tagsData;
13 |
14 | final _TagsDataProvider data;
15 | }
16 |
17 | class _TagsDataProvider {
18 | _TagsDataProvider({required this.tagsData});
19 |
20 | factory _TagsDataProvider.fromJson(Map json) => _TagsDataProvider(
21 | tagsData: TagsData.fromJson(json['ms.vss-code-web.commits-data-provider'] as Map),
22 | );
23 |
24 | final TagsData tagsData;
25 | }
26 |
27 | class TagsData {
28 | TagsData({required this.tags});
29 |
30 | factory TagsData.fromJson(Map json) => TagsData(
31 | tags: Map>.from(json['tags'] as Map).map(
32 | (k, v) => MapEntry>(k, List.from(v.map((a) => Tag.fromJson(a as Map)))),
33 | ),
34 | );
35 |
36 | final Map> tags;
37 | late String projectId;
38 | late String repositoryId;
39 | }
40 |
41 | class Tag {
42 | Tag({required this.name, required this.comment, required this.tagger, required this.resolvedCommitId});
43 |
44 | factory Tag.fromJson(Map json) => Tag(
45 | name: json['name'] as String,
46 | comment: json['comment'] as String?,
47 | tagger: json['tagger'] == null ? null : _Tagger.fromJson(json['tagger'] as Map),
48 | resolvedCommitId: json['resolvedCommitId'] as String,
49 | );
50 |
51 | final String name;
52 | final String? comment;
53 | final _Tagger? tagger;
54 | final String resolvedCommitId;
55 | }
56 |
57 | class _Tagger {
58 | _Tagger({required this.name, required this.email, required this.date});
59 |
60 | factory _Tagger.fromJson(Map json) => _Tagger(
61 | name: json['name'] as String?,
62 | email: json['email'] as String?,
63 | date: json['date'] == null ? null : DateTime.parse(json['date'] as String),
64 | );
65 |
66 | final String? name;
67 | final String? email;
68 | final DateTime? date;
69 | }
70 |
--------------------------------------------------------------------------------
/lib/src/widgets/lifecycle_listener.dart:
--------------------------------------------------------------------------------
1 | import 'dart:async';
2 | import 'dart:io';
3 |
4 | import 'package:azure_devops/src/extensions/context_extension.dart';
5 | import 'package:azure_devops/src/mixins/logger_mixin.dart';
6 | import 'package:azure_devops/src/services/share_intent_service.dart';
7 | import 'package:flutter/material.dart';
8 |
9 | class LifecycleListener extends StatefulWidget {
10 | const LifecycleListener({super.key, required this.child});
11 |
12 | final Widget child;
13 |
14 | @override
15 | State createState() => _LifecycleListenerState();
16 | }
17 |
18 | class _LifecycleListenerState extends State with WidgetsBindingObserver, AppLogger {
19 | Timer? _inactiveTimer;
20 | bool _hasAlreadyLogged = false;
21 | AppLifecycleState? _previousState;
22 |
23 | DateTime _lastSubscriptionCheck = DateTime.now();
24 |
25 | @override
26 | void initState() {
27 | super.initState();
28 | WidgetsBinding.instance.addObserver(this);
29 | }
30 |
31 | @override
32 | void dispose() {
33 | WidgetsBinding.instance.removeObserver(this);
34 | _inactiveTimer?.cancel();
35 | _inactiveTimer = null;
36 | super.dispose();
37 | }
38 |
39 | @override
40 | void didChangeAppLifecycleState(AppLifecycleState state) {
41 | final user = context.api.user;
42 |
43 | if (state == AppLifecycleState.inactive && _previousState != AppLifecycleState.paused) {
44 | if (user != null && !_hasAlreadyLogged) {
45 | logInfo('Session finished');
46 | _hasAlreadyLogged = true;
47 | _inactiveTimer = Timer(Duration(seconds: 300), () => _hasAlreadyLogged = false);
48 | }
49 | } else if (state == AppLifecycleState.resumed) {
50 | final now = DateTime.now();
51 | final shouldCheck = now.difference(_lastSubscriptionCheck) > Duration(hours: 1);
52 |
53 | if (shouldCheck && user != null) {
54 | logDebug('Session resumed');
55 | _checkSubscription();
56 | _lastSubscriptionCheck = now;
57 | }
58 |
59 | if (Platform.isAndroid && user != null) {
60 | ShareIntentService().maybeHandleSharedUrl();
61 | }
62 | }
63 |
64 | _previousState = state;
65 | }
66 |
67 | void _checkSubscription() {
68 | context.purchase.checkSubscription();
69 | }
70 |
71 | @override
72 | Widget build(BuildContext context) {
73 | return widget.child;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/lib/src/screens/commits/base_commits.dart:
--------------------------------------------------------------------------------
1 | library commits;
2 |
3 | import 'package:azure_devops/src/extensions/commit_extension.dart';
4 | import 'package:azure_devops/src/extensions/context_extension.dart';
5 | import 'package:azure_devops/src/mixins/ads_mixin.dart';
6 | import 'package:azure_devops/src/mixins/api_error_mixin.dart';
7 | import 'package:azure_devops/src/mixins/filter_mixin.dart';
8 | import 'package:azure_devops/src/models/commit.dart';
9 | import 'package:azure_devops/src/models/commits_tags.dart';
10 | import 'package:azure_devops/src/models/project.dart';
11 | import 'package:azure_devops/src/models/repository.dart';
12 | import 'package:azure_devops/src/models/user.dart';
13 | import 'package:azure_devops/src/router/router.dart';
14 | import 'package:azure_devops/src/services/ads_service.dart';
15 | import 'package:azure_devops/src/services/azure_api_service.dart';
16 | import 'package:azure_devops/src/services/filters_service.dart';
17 | import 'package:azure_devops/src/services/overlay_service.dart';
18 | import 'package:azure_devops/src/services/storage_service.dart';
19 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
20 | import 'package:azure_devops/src/theme/theme.dart';
21 | import 'package:azure_devops/src/widgets/ad_widget.dart';
22 | import 'package:azure_devops/src/widgets/app_base_page.dart';
23 | import 'package:azure_devops/src/widgets/app_page.dart';
24 | import 'package:azure_devops/src/widgets/commit_list_tile.dart';
25 | import 'package:azure_devops/src/widgets/filter_menu.dart';
26 | import 'package:azure_devops/src/widgets/shortcut_label.dart';
27 | import 'package:collection/collection.dart';
28 | import 'package:flutter/material.dart';
29 | import 'package:http/http.dart';
30 |
31 | part 'components_commits.dart';
32 | part 'controller_commits.dart';
33 | part 'parameters_commits.dart';
34 | part 'screen_commits.dart';
35 |
36 | class CommitsPage extends StatelessWidget {
37 | const CommitsPage();
38 |
39 | static const _smartphoneParameters = _CommitsParameters();
40 | static const _tabletParameters = _CommitsParameters();
41 |
42 | @override
43 | Widget build(BuildContext context) {
44 | final args = AppRouter.getCommitsArgs(context);
45 | return AppBasePage(
46 | initState: () => _CommitsController._(context.api, context.storage, args, context.ads),
47 | smartphone: (ctrl) => _CommitsScreen(ctrl, _smartphoneParameters),
48 | tablet: (ctrl) => _CommitsScreen(ctrl, _tabletParameters),
49 | );
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/pubspec.yaml:
--------------------------------------------------------------------------------
1 | name: 'azure_devops'
2 | version: 3.9.0+99
3 | description: 'Azure DevOps unofficial mobile app'
4 | homepage: 'homepage'
5 | publish_to: 'none'
6 |
7 | environment:
8 | sdk: '>=3.9.0 <4.0.0'
9 | flutter: '>=3.38.0'
10 |
11 | dependencies:
12 | flutter: { sdk: flutter }
13 | google_fonts: ^6.1.0
14 | http: ^1.1.0
15 | shared_preferences: ^2.1.1
16 | pull_to_refresh_flutter3: ^2.0.1
17 | intl: ^0.20.1
18 | url_launcher: ^6.1.11
19 | share_plus: ^12.0.1
20 | sentry_flutter: ^9.4.1
21 | cached_network_image: ^3.2.3
22 | package_info_plus: ^9.0.0
23 | flutter_markdown: ^0.7.4+3
24 | flutter_html:
25 | git:
26 | url: https://github.com/spectorasoftware/flutter_html
27 | ref: default-list-style-type
28 | flutter_highlighting:
29 | git:
30 | url: https://github.com/amake/dart-highlighting
31 | ref: text-rich
32 | path: flutter_highlighting
33 | highlighting: ^0.9.0+11.8.0
34 | in_app_review: ^2.0.6
35 | flutter_svg: ^2.0.5
36 | path: ^1.8.2
37 | path_provider: ^2.0.15
38 | open_file: ^3.5.10
39 | collection: ^1.17.1
40 | html_editor_enhanced: ^2.6.0
41 | visibility_detector: ^0.4.0
42 | firebase_core: ^4.2.1
43 | firebase_analytics: ^12.0.4
44 | file_picker: ^10.2.0
45 | msal_auth:
46 | git:
47 | url: https://github.com/PurpleSoftSrl/msal_auth
48 | ref: main
49 | xml: ^6.3.0
50 | purple_theme:
51 | git:
52 | url: https://github.com/PurpleSoftSrl/flutter_theme_manager
53 | ref: main
54 | google_mobile_ads: ^6.0.0
55 | purchases_flutter: ^9.9.10
56 |
57 | dev_dependencies:
58 | flutter_test: { sdk: flutter }
59 | flutter_launcher_icons: ^0.14.2
60 | purple_lints:
61 | git:
62 | url: https://github.com/PurpleSoftSrl/flutter_lints
63 | ref: main
64 |
65 |
66 | flutter:
67 | config:
68 | enable-swift-package-manager: false # google_mobile_ads does not support SPM
69 |
70 | uses-material-design: true
71 |
72 | assets:
73 | - assets/app_icon/
74 | - assets/illustrations/
75 | - assets/fonts/
76 | - assets/logos/
77 | - assets/msal_config.json
78 | - CHANGELOG.md
79 |
80 | fonts:
81 | - family: DevOpsIcons
82 | fonts:
83 | - asset: assets/fonts/DevOpsIcons.ttf
84 |
85 |
86 | flutter_icons:
87 | android: true
88 | ios: true
89 | image_path: "assets/app_icon/app_icon_ios.png"
90 | adaptive_icon_background: "#201F1E"
91 | adaptive_icon_foreground: "assets/app_icon/app_icon_android.png"
92 | remove_alpha_ios: true
--------------------------------------------------------------------------------
/lib/src/screens/saved_queries/screen_saved_queries.dart:
--------------------------------------------------------------------------------
1 | part of saved_queries;
2 |
3 | class _SavedQueriesScreen extends StatelessWidget {
4 | const _SavedQueriesScreen(this.ctrl, this.parameters);
5 |
6 | final _SavedQueriesController ctrl;
7 | final _SavedQueriesParameters parameters;
8 |
9 | @override
10 | Widget build(BuildContext context) {
11 | return AppPage(
12 | init: ctrl.init,
13 | title: ctrl.args.path,
14 | notifier: ctrl.savedQueries,
15 | builder: (query) => switch (query) {
16 | _ when query.isFolder && query.children.isEmpty => SizedBox(
17 | height: 600,
18 | child: const Center(child: Text('This folder is empty')),
19 | ),
20 | _ => Column(
21 | children: query.children
22 | .sortedBy((q) => q.path)
23 | .map(
24 | (q) => InkWell(
25 | onTap: () => ctrl.goToQuery(q),
26 | child: NavigationButton(
27 | margin: const EdgeInsets.only(top: 8),
28 | padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
29 | backgroundColor: q.isFolder ? null : Colors.transparent,
30 | child: Row(
31 | children: [
32 | Expanded(
33 | child: Text(
34 | q.path.replaceFirst('${ctrl.args.path}/', ''),
35 | style: context.textTheme.titleSmall!.copyWith(
36 | decoration: q.isFolder ? null : TextDecoration.underline,
37 | ),
38 | ),
39 | ),
40 | if (q.isFolder)
41 | const Icon(Icons.arrow_forward_ios)
42 | else
43 | DevOpsPopupMenu(
44 | tooltip: 'saved query actions',
45 | items: () => [
46 | PopupItem(text: 'Rename', icon: DevOpsIcons.edit, onTap: () => ctrl.renameQuery(q)),
47 | PopupItem(text: 'Delete', icon: DevOpsIcons.failed, onTap: () => ctrl.deleteQuery(q)),
48 | ],
49 | ),
50 | ],
51 | ),
52 | ),
53 | ),
54 | )
55 | .toList(),
56 | ),
57 | },
58 | );
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/lib/src/screens/settings/base_settings.dart:
--------------------------------------------------------------------------------
1 | library settings;
2 |
3 | import 'dart:async';
4 | import 'dart:io';
5 |
6 | import 'package:azure_devops/src/extensions/context_extension.dart';
7 | import 'package:azure_devops/src/mixins/logger_mixin.dart';
8 | import 'package:azure_devops/src/mixins/share_mixin.dart';
9 | import 'package:azure_devops/src/models/directory.dart';
10 | import 'package:azure_devops/src/models/organization.dart';
11 | import 'package:azure_devops/src/router/router.dart';
12 | import 'package:azure_devops/src/services/azure_api_service.dart';
13 | import 'package:azure_devops/src/services/msal_service.dart';
14 | import 'package:azure_devops/src/services/overlay_service.dart';
15 | import 'package:azure_devops/src/services/storage_service.dart';
16 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
17 | import 'package:azure_devops/src/theme/theme.dart';
18 | import 'package:azure_devops/src/utils/utils.dart';
19 | import 'package:azure_devops/src/widgets/app_base_page.dart';
20 | import 'package:azure_devops/src/widgets/app_page.dart';
21 | import 'package:azure_devops/src/widgets/loading_button.dart';
22 | import 'package:azure_devops/src/widgets/markdown_widget.dart';
23 | import 'package:azure_devops/src/widgets/navigation_button.dart';
24 | import 'package:azure_devops/src/widgets/section_header.dart';
25 | import 'package:collection/collection.dart';
26 | import 'package:flutter/material.dart';
27 | import 'package:flutter/services.dart';
28 | import 'package:flutter_markdown/flutter_markdown.dart';
29 | import 'package:in_app_review/in_app_review.dart';
30 | import 'package:package_info_plus/package_info_plus.dart';
31 | import 'package:purple_theme/purple_theme.dart';
32 | import 'package:url_launcher/link.dart';
33 | import 'package:url_launcher/url_launcher_string.dart';
34 |
35 | part 'components_settings.dart';
36 | part 'controller_settings.dart';
37 | part 'parameters_settings.dart';
38 | part 'screen_settings.dart';
39 |
40 | class SettingsPage extends StatelessWidget {
41 | const SettingsPage();
42 |
43 | static const _smartphoneParameters = _SettingsParameters();
44 | static const _tabletParameters = _SettingsParameters();
45 |
46 | @override
47 | Widget build(BuildContext context) {
48 | return AppBasePage(
49 | initState: () => _SettingsController._(context.api, context.storage),
50 | smartphone: (ctrl) => _SettingsScreen(ctrl, _smartphoneParameters),
51 | tablet: (ctrl) => _SettingsScreen(ctrl, _tabletParameters),
52 | );
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/lib/src/screens/tabs/controller_tabs.dart:
--------------------------------------------------------------------------------
1 | part of tabs;
2 |
3 | class _TabsController {
4 | _TabsController._();
5 |
6 | late List<_TabPage> navPages = _getTabPages();
7 |
8 | final GlobalKey tabKey = GlobalKey(debugLabel: 'tab_bar_key');
9 |
10 | int page = 0;
11 | int previousIndex = 0;
12 |
13 | final tabController = CupertinoTabController();
14 |
15 | Future init() async {
16 | navPages = _getTabPages();
17 |
18 | AppRouter.tabKeys = navPages.map((e) => e.key).toList();
19 | AppRouter.index = 0;
20 | }
21 |
22 | void popAll(GlobalKey key) {
23 | final currentKey = key;
24 | return currentKey.currentState?.popUntil((r) => r.isFirst);
25 | }
26 |
27 | void switchTab(int index) {
28 | previousIndex = page;
29 |
30 | if (page == index) popAll(navPages[index].key);
31 | page = index;
32 | AppRouter.index = index;
33 | }
34 |
35 | void goToTab(int index) {
36 | switchTab(index);
37 | }
38 |
39 | List<_TabPage> _getTabPages() {
40 | return <_TabPage>[
41 | _TabPage(pageName: AppRouter.home, icon: DevOpsIcons.home, key: GlobalKey()),
42 | _TabPage(pageName: AppRouter.boards, icon: DevOpsIcons.board, key: GlobalKey()),
43 | _TabPage(pageName: AppRouter.profile, icon: DevOpsIcons.profile, key: GlobalKey()),
44 | _TabPage(pageName: AppRouter.settings, icon: DevOpsIcons.settings, key: GlobalKey()),
45 | ];
46 | }
47 |
48 | String? getRouteName(RouteSettings settings, int i) {
49 | if (settings.name == null) return navPages[previousIndex].pageName;
50 | if (settings.name == '/') return navPages[i].pageName;
51 | return settings.name;
52 | }
53 |
54 | RouteSettings? getRouteSettingsName(RouteSettings? settings, int i) {
55 | if (settings?.name == null) return null;
56 | final routeName = getRouteName(settings!, i);
57 | if (routeName == null) return null;
58 | return RouteSettings(name: routeName);
59 | }
60 |
61 | void popTab({required bool didPop}) {
62 | if (didPop) return;
63 |
64 | final canPop = navPages[page].key.currentState!.canPop();
65 |
66 | if (canPop) return;
67 |
68 | AppRouter.askBeforeClosingApp(didPop: didPop);
69 | }
70 | }
71 |
72 | class _TabPage {
73 | _TabPage({required this.pageName, required this.icon, required this.key});
74 |
75 | final String pageName;
76 | final IconData icon;
77 | final GlobalKey key;
78 | }
79 |
--------------------------------------------------------------------------------
/lib/src/widgets/popup_menu.dart:
--------------------------------------------------------------------------------
1 | import 'package:azure_devops/src/extensions/context_extension.dart';
2 | import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
3 | import 'package:flutter/material.dart';
4 |
5 | class DevOpsPopupMenu extends StatelessWidget {
6 | const DevOpsPopupMenu({
7 | required this.tooltip,
8 | required this.items,
9 | this.offset = const Offset(0, 40),
10 | this.child,
11 | this.color,
12 | this.constraints,
13 | this.menuKey,
14 | });
15 |
16 | final String tooltip;
17 | final List Function() items;
18 | final Offset offset;
19 | final Widget? child;
20 | final Color? color;
21 | final BoxConstraints? constraints;
22 | final Key? menuKey;
23 |
24 | List> _getEffectiveItems(BuildContext context) {
25 | final builtItems = items();
26 | final effectiveItems = >[];
27 |
28 | for (final item in builtItems) {
29 | effectiveItems.add(
30 | PopupMenuItem(
31 | onTap: item.onTap,
32 | padding: const EdgeInsets.symmetric(horizontal: 10),
33 | height: 30,
34 | child:
35 | item.child ??
36 | Row(
37 | mainAxisAlignment: MainAxisAlignment.spaceBetween,
38 | children: [
39 | Flexible(child: Text(item.text, style: context.textTheme.titleSmall)),
40 | Icon(item.icon),
41 | ],
42 | ),
43 | ),
44 | );
45 |
46 | if (item != builtItems.last) effectiveItems.add(const PopupMenuDivider());
47 | }
48 |
49 | return effectiveItems;
50 | }
51 |
52 | @override
53 | Widget build(BuildContext context) {
54 | return PopupMenuButton(
55 | key: menuKey ?? ValueKey('Popup menu $tooltip'),
56 | itemBuilder: _getEffectiveItems,
57 | elevation: 5,
58 | surfaceTintColor: Colors.transparent,
59 | shadowColor: Colors.black,
60 | tooltip: tooltip,
61 | offset: offset,
62 | color: color,
63 | constraints: constraints,
64 | shape: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
65 | child: child ?? Icon(DevOpsIcons.dots_horizontal),
66 | );
67 | }
68 | }
69 |
70 | class PopupItem {
71 | PopupItem({required this.text, this.icon, required this.onTap, this.child});
72 |
73 | final String text;
74 | final IconData? icon;
75 | final VoidCallback onTap;
76 | final Widget? child;
77 | }
78 |
--------------------------------------------------------------------------------