Skip to content

Commit 95b489e

Browse files
committed
add switch directory support
Part of #9
1 parent 2abffe2 commit 95b489e

File tree

12 files changed

+294
-58
lines changed

12 files changed

+294
-58
lines changed

android/app/build.gradle

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@ android {
9494
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
9595
}
9696
}
97+
98+
packaging {
99+
resources {
100+
excludes += [
101+
'META-INF/DEPENDENCIES',
102+
'META-INF/LICENSE',
103+
'META-INF/LICENSE.txt',
104+
'META-INF/license.txt',
105+
'META-INF/NOTICE',
106+
'META-INF/NOTICE.txt',
107+
'META-INF/notice.txt'
108+
]
109+
}
110+
}
97111
}
98112

99113
flutter {

ios/Podfile.lock

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,12 @@ PODS:
148148
- GoogleUtilities/Privacy
149149
- in_app_review (2.0.0):
150150
- Flutter
151-
- MSAL (1.7.0):
152-
- MSAL/app-lib (= 1.7.0)
153-
- MSAL/app-lib (1.7.0)
151+
- MSAL (2.2.0):
152+
- MSAL/app-lib (= 2.2.0)
153+
- MSAL/app-lib (2.2.0)
154154
- msal_auth (2.0.2):
155155
- Flutter
156-
- MSAL (~> 1.7.0)
156+
- MSAL (~> 2.2.0)
157157
- nanopb (3.30910.0):
158158
- nanopb/decode (= 3.30910.0)
159159
- nanopb/encode (= 3.30910.0)
@@ -308,8 +308,8 @@ SPEC CHECKSUMS:
308308
GoogleUserMessagingPlatform: f8d0cdad3ca835406755d0a69aa634f00e76d576
309309
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
310310
in_app_review: 5596fe56fab799e8edb3561c03d053363ab13457
311-
MSAL: 088bc688c1ce1a62f0d9c13905ddb31f8f1ac700
312-
msal_auth: 4b85b2f20923d88afe74f3ec09b2f3458ec6b335
311+
MSAL: 551bfa97974d3f6a82f9f53312976b9fa5904a52
312+
msal_auth: cb3f482e2f72e1181b1d476ff3a36c5ab560f29c
313313
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
314314
open_file_ios: 5ff7526df64e4394b4fe207636b67a95e83078bb
315315
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94

lib/src/models/directory.dart

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import 'dart:convert';
2+
3+
import 'package:http/http.dart';
4+
5+
class GetDirectoriesResponse {
6+
GetDirectoriesResponse({required this.data});
7+
8+
factory GetDirectoriesResponse.fromJson(Map<String, dynamic> json) => GetDirectoriesResponse(
9+
data: DataProviders.fromJson(json['dataProviders'] as Map<String, dynamic>? ?? {}),
10+
);
11+
12+
static MsVssTfsWebTenantPickerDataProvider fromResponse(Response res) =>
13+
GetDirectoriesResponse.fromJson(jsonDecode(res.body) as Map<String, dynamic>).data.provider;
14+
15+
final DataProviders data;
16+
}
17+
18+
class DataProviders {
19+
DataProviders({required this.provider});
20+
21+
factory DataProviders.fromJson(Map<String, dynamic> json) => DataProviders(
22+
provider: MsVssTfsWebTenantPickerDataProvider.fromJson(
23+
json['ms.vss-tfs-web.tenant-picker-data-provider'] as Map<String, dynamic>? ?? {}),
24+
);
25+
26+
final MsVssTfsWebTenantPickerDataProvider provider;
27+
}
28+
29+
class MsVssTfsWebTenantPickerDataProvider {
30+
MsVssTfsWebTenantPickerDataProvider({required this.tenantData, required this.user});
31+
32+
factory MsVssTfsWebTenantPickerDataProvider.fromJson(Map<String, dynamic> json) =>
33+
MsVssTfsWebTenantPickerDataProvider(
34+
tenantData: UserTenantData.fromJson(json['userTenantData'] as Map<String, dynamic>? ?? {}),
35+
user: User.fromJson(json['user'] as Map<String, dynamic>? ?? {}),
36+
);
37+
38+
final UserTenantData tenantData;
39+
final User user;
40+
}
41+
42+
class UserTenantData {
43+
UserTenantData({required this.tenants});
44+
45+
factory UserTenantData.fromJson(Map<String, dynamic> json) => UserTenantData(
46+
tenants: List<UserTenant>.from(
47+
(json['userTenants'] as List<dynamic>? ?? []).map((x) => UserTenant.fromJson(x as Map<String, dynamic>))),
48+
);
49+
50+
final List<UserTenant> tenants;
51+
}
52+
53+
class UserTenant {
54+
UserTenant({
55+
required this.displayName,
56+
required this.id,
57+
required this.authUrl,
58+
this.isCurrent = false,
59+
});
60+
61+
factory UserTenant.fromJson(Map<String, dynamic> json) => UserTenant(
62+
displayName: json['displayName'] as String? ?? '',
63+
id: json['id'] as String? ?? '',
64+
authUrl: json['authUrl'] as String? ?? '',
65+
);
66+
67+
factory UserTenant.current({required String displayName, required String id}) => UserTenant(
68+
displayName: displayName,
69+
id: id,
70+
authUrl: '',
71+
isCurrent: true,
72+
);
73+
74+
final String displayName;
75+
final String id;
76+
final String authUrl;
77+
78+
final bool isCurrent;
79+
}
80+
81+
class User {
82+
User({
83+
required this.name,
84+
required this.id,
85+
required this.email,
86+
required this.tenant,
87+
});
88+
89+
factory User.fromJson(Map<String, dynamic> json) => User(
90+
name: json['name'] as String? ?? '',
91+
id: json['id'] as String? ?? '',
92+
email: json['email'] as String? ?? '',
93+
tenant: Tenant.fromJson(json['tenant'] as Map<String, dynamic>? ?? {}),
94+
);
95+
96+
final String name;
97+
final String id;
98+
final String email;
99+
final Tenant tenant;
100+
}
101+
102+
class Tenant {
103+
Tenant({
104+
required this.displayName,
105+
required this.id,
106+
required this.domain,
107+
});
108+
109+
factory Tenant.fromJson(Map<String, dynamic> json) => Tenant(
110+
displayName: json['displayName'] as String? ?? '',
111+
id: json['id'] as String? ?? '',
112+
domain: json['domain'] as String? ?? '',
113+
);
114+
115+
final String displayName;
116+
final String id;
117+
final String domain;
118+
}

lib/src/screens/settings/base_settings.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import 'dart:io';
66
import 'package:azure_devops/src/extensions/context_extension.dart';
77
import 'package:azure_devops/src/mixins/logger_mixin.dart';
88
import 'package:azure_devops/src/mixins/share_mixin.dart';
9-
import 'package:azure_devops/src/models/organization.dart';
9+
import 'package:azure_devops/src/models/directory.dart';
1010
import 'package:azure_devops/src/router/router.dart';
1111
import 'package:azure_devops/src/services/azure_api_service.dart';
1212
import 'package:azure_devops/src/services/msal_service.dart';
@@ -17,10 +17,10 @@ import 'package:azure_devops/src/theme/theme.dart';
1717
import 'package:azure_devops/src/utils/utils.dart';
1818
import 'package:azure_devops/src/widgets/app_base_page.dart';
1919
import 'package:azure_devops/src/widgets/app_page.dart';
20-
import 'package:azure_devops/src/widgets/loading_button.dart';
2120
import 'package:azure_devops/src/widgets/markdown_widget.dart';
2221
import 'package:azure_devops/src/widgets/navigation_button.dart';
2322
import 'package:azure_devops/src/widgets/section_header.dart';
23+
import 'package:collection/collection.dart';
2424
import 'package:flutter/material.dart';
2525
import 'package:flutter/services.dart';
2626
import 'package:flutter_markdown/flutter_markdown.dart';

lib/src/screens/settings/components_settings.dart

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,49 @@ class _ThemeModeRadio extends StatelessWidget {
3939
);
4040
}
4141
}
42+
43+
class _SwitchDirectoryWidget extends StatelessWidget {
44+
const _SwitchDirectoryWidget({
45+
required this.directories,
46+
required this.onSwitch,
47+
});
48+
49+
final List<UserTenant> directories;
50+
final Future<void> Function(UserTenant) onSwitch;
51+
52+
@override
53+
Widget build(BuildContext context) {
54+
final currentDirectory = directories.firstWhereOrNull((d) => d.isCurrent);
55+
return ListView(
56+
children: [
57+
if (currentDirectory != null) ...[
58+
Text(
59+
'Current directory',
60+
style: context.textTheme.titleSmall!.copyWith(color: context.colorScheme.onSecondary),
61+
),
62+
ListTile(
63+
title: Text(currentDirectory.displayName, style: context.textTheme.bodyMedium),
64+
contentPadding: EdgeInsets.zero,
65+
),
66+
],
67+
const SizedBox(height: 20),
68+
Text(
69+
'Other directories',
70+
style: context.textTheme.titleSmall!.copyWith(color: context.colorScheme.onSecondary),
71+
),
72+
...ListTile.divideTiles(
73+
context: context,
74+
tiles: [
75+
for (final tenant in directories.where((d) => !d.isCurrent))
76+
ListTile(
77+
title: Text(tenant.displayName, style: context.textTheme.bodyMedium),
78+
trailing: Icon(Icons.arrow_forward_ios),
79+
onTap: () => onSwitch(tenant),
80+
contentPadding: EdgeInsets.zero,
81+
),
82+
],
83+
),
84+
],
85+
);
86+
}
87+
}

lib/src/screens/settings/controller_settings.dart

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,15 @@ class _SettingsController with ShareMixin, AppLogger {
1010

1111
String appVersion = '';
1212

13-
final organizations = ValueNotifier<ApiResponse<List<Organization>>?>(null);
14-
15-
bool get hasMultiOrgs => (organizations.value?.data?.length ?? 0) > 1;
13+
final directories = ValueNotifier<ApiResponse<List<UserTenant>>?>(null);
1614

1715
Future<void> init() async {
1816
final info = await PackageInfo.fromPlatform();
1917
appVersion = info.version;
2018

21-
final orgs = await api.getOrganizations();
22-
// copyWith is needed to make page visible even if getOrganizations returns 401
23-
organizations.value = orgs.copyWith(isError: false, data: []);
19+
final orgs = await api.getDirectories();
20+
// copyWith is needed to make page visible even if getDirectories returns 401
21+
directories.value = orgs.copyWith(isError: false, data: orgs.data ?? []);
2422
}
2523

2624
void shareApp() {
@@ -78,44 +76,43 @@ class _SettingsController with ShareMixin, AppLogger {
7876
InAppReview.instance.openStoreListing(appStoreId: '1666994628');
7977
}
8078

81-
Future<void> switchOrganization() async {
82-
final selectedOrg = await _selectOrganization(organizations.value!.data!);
83-
if (selectedOrg == null) return;
79+
Future<void> chooseDirectory() async {
80+
await OverlayService.bottomsheet(
81+
title: 'Switch directory',
82+
isScrollControlled: true,
83+
heightPercentage: .6,
84+
builder: (context) => _SwitchDirectoryWidget(
85+
directories: directories.value?.data ?? [],
86+
onSwitch: _switchOrganization,
87+
),
88+
);
89+
}
90+
91+
Future<void> _switchOrganization(UserTenant tenant) async {
92+
final token = await MsalService().login(authority: 'https://login.microsoftonline.com/${tenant.id}');
8493

85-
api.switchOrganization(selectedOrg.accountName!);
86-
unawaited(AppRouter.goToSplash());
94+
storage.setTenantId(tenant.id);
95+
96+
if (token != null) unawaited(_loginAndNavigate(token));
8797
}
8898

89-
Future<Organization?> _selectOrganization(List<Organization> organizations) async {
90-
final currentOrg = storage.getOrganization();
99+
Future<void> _loginAndNavigate(String token) async {
100+
storage.setOrganization('');
91101

92-
Organization? selectedOrg;
102+
final isLogged = await api.login(token);
93103

94-
await OverlayService.bottomsheet(
95-
title: 'Select your organization',
96-
isScrollControlled: true,
97-
heightPercentage: .7,
98-
builder: (context) => ListView(
99-
children: organizations
100-
.map(
101-
(org) => Padding(
102-
padding: const EdgeInsets.symmetric(vertical: 10),
103-
child: LoadingButton(
104-
onPressed: () {
105-
selectedOrg = org;
106-
AppRouter.popRoute();
107-
},
108-
text: org.accountName == currentOrg ? '${org.accountName!} (current)' : org.accountName!,
109-
),
110-
),
111-
)
112-
.toList(),
113-
),
114-
);
104+
final isFailed = [LoginStatus.failed, LoginStatus.unauthorized].contains(isLogged);
105+
106+
logAnalytics('switch_directory_${isFailed ? 'failed' : 'success'}', {});
115107

116-
if (selectedOrg?.accountName == currentOrg) return null;
108+
if (isLogged == LoginStatus.failed) {
109+
return OverlayService.error(
110+
'Login error',
111+
description: 'Check that you have access to the organization you are trying to switch to.',
112+
);
113+
}
117114

118-
return selectedOrg;
115+
await AppRouter.goToChooseProjects();
119116
}
120117

121118
Future<void> showChangelog() async {

lib/src/screens/settings/screen_settings.dart

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ class _SettingsScreen extends StatelessWidget {
1111
return AppPage(
1212
init: ctrl.init,
1313
title: 'Settings',
14-
notifier: ctrl.organizations,
14+
notifier: ctrl.directories,
1515
actions: [
1616
IconButton(
1717
onPressed: ctrl.shareApp,
1818
icon: Icon(DevOpsIcons.share),
1919
),
2020
],
21-
builder: (orgs) => Column(
21+
builder: (directories) => Column(
2222
crossAxisAlignment: CrossAxisAlignment.start,
2323
children: [
2424
SectionHeader.noMargin(
@@ -106,20 +106,20 @@ class _SettingsScreen extends StatelessWidget {
106106
],
107107
),
108108
),
109-
if (orgs.length > 1) ...[
109+
if (directories.length > 1) ...[
110110
const SizedBox(
111111
height: 20,
112112
),
113113
NavigationButton(
114-
onTap: ctrl.switchOrganization,
114+
onTap: ctrl.chooseDirectory,
115115
child: Row(
116116
children: [
117117
Icon(DevOpsIcons.repository),
118118
const SizedBox(
119119
width: 20,
120120
),
121121
Text(
122-
'Switch organization',
122+
'Switch directory',
123123
style: context.textTheme.bodyLarge,
124124
),
125125
const Spacer(),

0 commit comments

Comments
 (0)