├── 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 | [![license](https://img.shields.io/badge/License-MIT-yellow.svg)](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 | --------------------------------------------------------------------------------