Added test for login page

This commit is contained in:
Anton Stubenbord
2022-12-05 19:15:00 +01:00
parent 0a63259693
commit d79682a011
20 changed files with 444 additions and 50 deletions

View File

@@ -53,6 +53,7 @@ android {
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
@@ -77,4 +78,8 @@ flutter {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

View File

@@ -1,17 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('screenshot', (WidgetTester tester) async {
// Build the app.
// This is required prior to taking the screenshot (Android only).
await binding.convertFlutterSurfaceToImage();
// Trigger a frame.
await tester.pumpAndSettle();
await binding.takeScreenshot('screenshot-1');
});
}

View File

@@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/bloc/connectivity_cubit.dart';
import 'package:paperless_mobile/core/service/connectivity_status.service.dart';
import 'package:paperless_mobile/core/store/local_vault.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/di_test_mocks.mocks.dart';
import 'package:paperless_mobile/features/login/bloc/authentication_cubit.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
import 'package:paperless_mobile/features/settings/model/view_type.dart';
import 'src/framework.dart';
void main() async {
final t = await initializeTestingFramework(languageCode: 'de');
const testServerUrl = 'https://example.com';
const testUsername = 'user';
const testPassword = 'pass';
final serverAddressField = find.byKey(const ValueKey('login-server-address'));
final usernameField = find.byKey(const ValueKey('login-username'));
final passwordField = find.byKey(const ValueKey('login-password'));
final loginBtn = find.byKey(const ValueKey('login-login-button'));
testWidgets('Test successful login flow', (WidgetTester tester) async {
await initAndLaunchTestApp(tester, () async {
// Initialize dat for mocked classes
when((getIt<ConnectivityStatusService>()).connectivityChanges())
.thenAnswer((i) => Stream.value(true));
when((getIt<LocalVault>() as MockLocalVault)
.loadAuthenticationInformation())
.thenAnswer((realInvocation) async => null);
when((getIt<LocalVault>() as MockLocalVault).loadApplicationSettings())
.thenAnswer((realInvocation) async => ApplicationSettingsState(
preferredLocaleSubtag: 'en',
preferredThemeMode: ThemeMode.light,
isLocalAuthenticationEnabled: false,
preferredViewType: ViewType.list,
showInboxOnStartup: false,
));
when(getIt<PaperlessAuthenticationApi>().login(
username: testUsername,
password: testPassword,
serverUrl: testServerUrl,
)).thenAnswer((i) => Future.value("eyTestToken"));
await getIt<ConnectivityCubit>().initialize();
await getIt<ApplicationSettingsCubit>().initialize();
await getIt<AuthenticationCubit>().initialize();
});
// Mocked classes
await t.binding.waitUntilFirstFrameRasterized;
await tester.pumpAndSettle();
await tester.enterText(serverAddressField, testServerUrl);
await tester.pumpAndSettle();
await tester.enterText(usernameField, testUsername);
await tester.pumpAndSettle();
await tester.enterText(passwordField, testPassword);
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
await tester.tap(loginBtn);
verify(getIt<PaperlessAuthenticationApi>().login(
username: testUsername,
password: testPassword,
serverUrl: testServerUrl,
)).called(1);
});
testWidgets('Test login validation missing password',
(WidgetTester tester) async {
await initAndLaunchTestApp(tester, () async {
when((getIt<ConnectivityStatusService>() as MockConnectivityStatusService)
.connectivityChanges())
.thenAnswer((i) => Stream.value(true));
when((getIt<LocalVault>() as MockLocalVault)
.loadAuthenticationInformation())
.thenAnswer((realInvocation) async => null);
when((getIt<LocalVault>() as MockLocalVault).loadApplicationSettings())
.thenAnswer((realInvocation) async => ApplicationSettingsState(
preferredLocaleSubtag: 'en',
preferredThemeMode: ThemeMode.light,
isLocalAuthenticationEnabled: false,
preferredViewType: ViewType.list,
showInboxOnStartup: false,
));
await getIt<ConnectivityCubit>().initialize();
await getIt<ApplicationSettingsCubit>().initialize();
await getIt<AuthenticationCubit>().initialize();
});
// Mocked classes
// Initialize dat for mocked classes
await t.binding.waitUntilFirstFrameRasterized;
await tester.pumpAndSettle();
await tester.enterText(serverAddressField, testServerUrl);
await tester.pumpAndSettle();
await tester.enterText(usernameField, testUsername);
await tester.pumpAndSettle();
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
await tester.tap(loginBtn);
await tester.pumpAndSettle();
verifyNever(
(getIt<PaperlessAuthenticationApi>() as MockPaperlessAuthenticationApi)
.login(
username: testUsername,
password: testPassword,
serverUrl: testServerUrl,
));
expect(
find.textContaining(t.translations.loginPagePasswordValidatorMessageText),
findsOneWidget,
);
});
testWidgets('Test login validation missing username',
(WidgetTester tester) async {
await initAndLaunchTestApp(tester, () async {
when((getIt<ConnectivityStatusService>() as MockConnectivityStatusService)
.connectivityChanges())
.thenAnswer((i) => Stream.value(true));
when((getIt<LocalVault>() as MockLocalVault)
.loadAuthenticationInformation())
.thenAnswer((realInvocation) async => null);
when((getIt<LocalVault>() as MockLocalVault).loadApplicationSettings())
.thenAnswer((realInvocation) async => ApplicationSettingsState(
preferredLocaleSubtag: 'en',
preferredThemeMode: ThemeMode.light,
isLocalAuthenticationEnabled: false,
preferredViewType: ViewType.list,
showInboxOnStartup: false,
));
await getIt<ConnectivityCubit>().initialize();
await getIt<ApplicationSettingsCubit>().initialize();
await getIt<AuthenticationCubit>().initialize();
});
await t.binding.waitUntilFirstFrameRasterized;
await tester.pumpAndSettle();
await tester.enterText(serverAddressField, testServerUrl);
await tester.pumpAndSettle();
await tester.enterText(passwordField, testPassword);
await tester.pumpAndSettle();
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
await tester.tap(loginBtn);
await tester.pumpAndSettle();
verifyNever(
(getIt<PaperlessAuthenticationApi>() as MockPaperlessAuthenticationApi)
.login(
username: testUsername,
password: testPassword,
serverUrl: testServerUrl,
));
expect(
find.textContaining(t.translations.loginPageUsernameValidatorMessageText),
findsOneWidget,
);
});
testWidgets('Test login validation missing server address',
(WidgetTester tester) async {
initAndLaunchTestApp(tester, () async {
when((getIt<ConnectivityStatusService>()).connectivityChanges())
.thenAnswer((i) => Stream.value(true));
when((getIt<LocalVault>()).loadAuthenticationInformation())
.thenAnswer((realInvocation) async => null);
when((getIt<LocalVault>()).loadApplicationSettings())
.thenAnswer((realInvocation) async => ApplicationSettingsState(
preferredLocaleSubtag: 'en',
preferredThemeMode: ThemeMode.light,
isLocalAuthenticationEnabled: false,
preferredViewType: ViewType.list,
showInboxOnStartup: false,
));
await getIt<ConnectivityCubit>().initialize();
await getIt<ApplicationSettingsCubit>().initialize();
await getIt<AuthenticationCubit>().initialize();
});
await t.binding.waitUntilFirstFrameRasterized;
await tester.pumpAndSettle();
await tester.enterText(usernameField, testUsername);
await tester.pumpAndSettle();
await tester.enterText(passwordField, testPassword);
await tester.pumpAndSettle();
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
await tester.tap(loginBtn);
await tester.pumpAndSettle();
verifyNever(getIt<PaperlessAuthenticationApi>().login(
username: testUsername,
password: testPassword,
serverUrl: testServerUrl,
));
expect(
find.textContaining(
t.translations.loginPageServerUrlValidatorMessageText),
findsOneWidget,
);
});
}

View File

@@ -0,0 +1,41 @@
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:paperless_mobile/di_initializer.dart';
import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/main.dart';
Future<TestingFrameworkVariables> initializeTestingFramework(
{String languageCode = 'en'}) async {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
configureDependencies('test');
final translations = await S.load(
Locale.fromSubtags(
languageCode: languageCode,
),
);
return TestingFrameworkVariables(
binding: binding,
translations: translations,
);
}
class TestingFrameworkVariables {
final IntegrationTestWidgetsFlutterBinding binding;
final S translations;
TestingFrameworkVariables({
required this.binding,
required this.translations,
});
}
Future<void> initAndLaunchTestApp(
WidgetTester tester,
Future<void> Function() initializationCallback,
) async {
await initializationCallback();
runApp(const PaperlessMobileEntrypoint());
}

View File

@@ -7,28 +7,30 @@ import 'package:injectable/injectable.dart';
@singleton
class ConnectivityCubit extends Cubit<ConnectivityState> {
final ConnectivityStatusService connectivityStatusService;
late final StreamSubscription<bool> _sub;
StreamSubscription<bool>? _sub;
ConnectivityCubit(this.connectivityStatusService)
: super(ConnectivityState.undefined);
Future<void> initialize() async {
final bool isConnected =
await connectivityStatusService.isConnectedToInternet();
emit(isConnected
? ConnectivityState.connected
: ConnectivityState.notConnected);
_sub =
connectivityStatusService.connectivityChanges().listen((isConnected) {
if (_sub == null) {
final bool isConnected =
await connectivityStatusService.isConnectedToInternet();
emit(isConnected
? ConnectivityState.connected
: ConnectivityState.notConnected);
});
_sub =
connectivityStatusService.connectivityChanges().listen((isConnected) {
emit(isConnected
? ConnectivityState.connected
: ConnectivityState.notConnected);
});
}
}
@override
Future<void> close() {
_sub.cancel();
_sub?.cancel();
return super.close();
}
}

View File

@@ -7,6 +7,8 @@ import 'package:http_interceptor/http_interceptor.dart';
import 'package:injectable/injectable.dart';
@injectable
@dev
@prod
class AuthenticationInterceptor implements InterceptorContract {
final LocalVault _localVault;
AuthenticationInterceptor(this._localVault);

View File

@@ -5,6 +5,8 @@ import 'package:injectable/injectable.dart';
const interceptedRoutes = ['thumb/'];
@injectable
@dev
@prod
class ResponseConversionInterceptor implements InterceptorContract {
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async =>

View File

@@ -13,6 +13,8 @@ import 'package:injectable/injectable.dart';
/// Convenience class which handles timeout errors.
///
@Injectable(as: BaseClient)
@dev
@prod
@Named("timeoutClient")
class TimeoutClient implements BaseClient {
final ConnectivityStatusService connectivityStatusService;

View File

@@ -9,7 +9,7 @@ abstract class ConnectivityStatusService {
Stream<bool> connectivityChanges();
}
@Injectable(as: ConnectivityStatusService)
@Injectable(as: ConnectivityStatusService, env: ['prod', 'dev'])
class ConnectivityStatusServiceImpl implements ConnectivityStatusService {
final Connectivity connectivity;

View File

@@ -8,15 +8,27 @@ import 'package:paperless_mobile/features/login/model/client_certificate.dart';
import 'package:paperless_mobile/features/settings/model/application_settings_state.dart';
import 'package:injectable/injectable.dart';
@singleton
class LocalVault {
abstract class LocalVault {
Future<void> storeAuthenticationInformation(AuthenticationInformation auth);
Future<AuthenticationInformation?> loadAuthenticationInformation();
Future<ClientCertificate?> loadCertificate();
Future<bool> storeApplicationSettings(ApplicationSettingsState settings);
Future<ApplicationSettingsState?> loadApplicationSettings();
Future<void> clear();
}
@Injectable(as: LocalVault)
@prod
@dev
class LocalVaultImpl implements LocalVault {
static const applicationSettingsKey = "applicationSettings";
static const authenticationKey = "authentication";
final EncryptedSharedPreferences sharedPreferences;
LocalVault(this.sharedPreferences);
LocalVaultImpl(this.sharedPreferences);
@override
Future<void> storeAuthenticationInformation(
AuthenticationInformation auth,
) async {
@@ -26,6 +38,7 @@ class LocalVault {
);
}
@override
Future<AuthenticationInformation?> loadAuthenticationInformation() async {
if ((await sharedPreferences.getString(authenticationKey)).isEmpty) {
return null;
@@ -35,11 +48,13 @@ class LocalVault {
);
}
@override
Future<ClientCertificate?> loadCertificate() async {
return loadAuthenticationInformation()
.then((value) => value?.clientCertificate);
}
@override
Future<bool> storeApplicationSettings(ApplicationSettingsState settings) {
return sharedPreferences.setString(
applicationSettingsKey,
@@ -47,6 +62,7 @@ class LocalVault {
);
}
@override
Future<ApplicationSettingsState?> loadApplicationSettings() async {
final settings = await sharedPreferences.getString(applicationSettingsKey);
if (settings.isEmpty) {
@@ -58,6 +74,7 @@ class LocalVault {
);
}
@override
Future<void> clear() {
return sharedPreferences.clear();
}

View File

@@ -7,13 +7,13 @@ import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
final getIt = GetIt.instance..allowReassignment;
@InjectableInit(
initializerName: r'$initGetIt', // default
preferRelativeImports: true, // default
asExtension: false, // default
)
void configureDependencies() => $initGetIt(getIt);
void configureDependencies(String environment) =>
$initGetIt(getIt, environment: environment);
///
/// Registers new security context, which will be used by the HttpClient, see [RegisterModule].

View File

@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/interceptor/authentication.interceptor.dart';
@@ -16,18 +17,33 @@ import 'package:local_auth/local_auth.dart';
@module
abstract class RegisterModule {
@singleton
@dev
@prod
LocalAuthentication get localAuthentication => LocalAuthentication();
@singleton
@dev
@prod
EncryptedSharedPreferences get encryptedSharedPreferences =>
EncryptedSharedPreferences();
@singleton
@dev
@prod
@test
SecurityContext get securityContext => SecurityContext();
@singleton
@dev
@prod
Connectivity get connectivity => Connectivity();
///
/// Factory method creating an [HttpClient] with the currently registered [SecurityContext].
///
@injectable
@dev
@prod
HttpClient getHttpClient(SecurityContext securityContext) =>
HttpClient(context: securityContext)
..connectionTimeout = const Duration(seconds: 10);
@@ -35,6 +51,9 @@ abstract class RegisterModule {
///
/// Factory method creating a [InterceptedClient] on top of the currently registered [HttpClient].
///
@injectable
@dev
@prod
BaseClient getBaseClient(
AuthenticationInterceptor authInterceptor,
ResponseConversionInterceptor responseConversionInterceptor,
@@ -50,28 +69,46 @@ abstract class RegisterModule {
client: IOClient(client),
);
@injectable
@dev
@prod
CacheManager getCacheManager(BaseClient client) => CacheManager(
Config('cacheKey', fileService: HttpFileService(httpClient: client)));
@injectable
@dev
@prod
PaperlessAuthenticationApi authenticationModule(BaseClient client) =>
PaperlessAuthenticationApiImpl(client);
@injectable
@dev
@prod
PaperlessLabelsApi labelsModule(
@Named('timeoutClient') BaseClient timeoutClient,
) =>
PaperlessLabelApiImpl(timeoutClient);
@injectable
@dev
@prod
PaperlessDocumentsApi documentsModule(
@Named('timeoutClient') BaseClient timeoutClient,
HttpClient httpClient,
) =>
PaperlessDocumentsApiImpl(timeoutClient, httpClient);
@injectable
@dev
@prod
PaperlessSavedViewsApi savedViewsModule(
@Named('timeoutClient') BaseClient timeoutClient,
) =>
PaperlessSavedViewsApiImpl(timeoutClient);
@injectable
@dev
@prod
PaperlessServerStatsApi serverStatsModule(
@Named('timeoutClient') BaseClient timeoutClient,
) =>

69
lib/di_test_mocks.dart Normal file
View File

@@ -0,0 +1,69 @@
import 'package:encrypted_shared_preferences/encrypted_shared_preferences.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:injectable/injectable.dart';
import 'package:local_auth/local_auth.dart';
import 'package:mockito/annotations.dart';
@GenerateNiceMocks([
MockSpec<PaperlessDocumentsApi>(),
MockSpec<PaperlessLabelsApi>(),
MockSpec<PaperlessSavedViewsApi>(),
MockSpec<PaperlessAuthenticationApi>(),
MockSpec<PaperlessServerStatsApi>(),
MockSpec<LocalVault>(),
MockSpec<EncryptedSharedPreferences>(),
MockSpec<ConnectivityStatusService>(),
MockSpec<LocalAuthentication>(),
])
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/service/connectivity_status.service.dart';
import 'package:paperless_mobile/core/store/local_vault.dart';
import 'di_test_mocks.mocks.dart';
@module
abstract class DiMocksModule {
// All fields must be singleton in order to verify behavior in tests.
@singleton
@test
CacheManager get testCacheManager => CacheManager(Config('testKey'));
@singleton
@test
PaperlessDocumentsApi get mockDocumentsApi => MockPaperlessDocumentsApi();
@singleton
@test
PaperlessLabelsApi get mockLabelsApi => MockPaperlessLabelsApi();
@singleton
@test
PaperlessSavedViewsApi get mockSavedViewsApi => MockPaperlessSavedViewsApi();
@singleton
@test
PaperlessAuthenticationApi get mockAuthenticationApi =>
MockPaperlessAuthenticationApi();
@singleton
@test
PaperlessServerStatsApi get mockServerStatsApi =>
MockPaperlessServerStatsApi();
@singleton
@test
LocalVault get mockLocalVault => MockLocalVault();
@singleton
@test
EncryptedSharedPreferences get mockSharedPreferences =>
MockEncryptedSharedPreferences();
@singleton
@test
ConnectivityStatusService get mockConnectivityStatusService =>
MockConnectivityStatusService();
@singleton
@test
LocalAuthentication get localAuthentication => MockLocalAuthentication();
}

View File

@@ -72,6 +72,7 @@ class _LoginPageState extends State<LoginPage> {
Widget _buildLoginButton() {
return ElevatedButton(
key: const ValueKey('login-login-button'),
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.primaryContainer,

View File

@@ -23,6 +23,7 @@ class _ClientCertificateFormFieldState
@override
Widget build(BuildContext context) {
return FormBuilderField<ClientCertificate?>(
key: const ValueKey('login-client-cert'),
initialValue: null,
validator: (value) {
if (value == null) {
@@ -70,6 +71,7 @@ class _ClientCertificateFormFieldState
),
if (_selectedFile != null) ...[
ObscuredInputTextFormField(
key: const ValueKey('login-client-cert-passphrase'),
initialValue: field.value?.passphrase,
onChanged: (value) => field.didChange(
field.value?.copyWith(passphrase: value),

View File

@@ -21,6 +21,7 @@ class _ServerAddressFormFieldState extends State<ServerAddressFormField> {
@override
Widget build(BuildContext context) {
return FormBuilderTextField(
key: const ValueKey('login-server-address'),
name: ServerAddressFormField.fkServerAddress,
validator: FormBuilderValidators.required(
errorText: S.of(context).loginPageServerUrlValidatorMessageText,

View File

@@ -24,6 +24,7 @@ class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
child: Column(
children: [
TextFormField(
key: const ValueKey('login-username'),
textCapitalization: TextCapitalization.words,
autovalidateMode: AutovalidateMode.onUserInteraction,
// USERNAME
@@ -41,6 +42,7 @@ class _UserCredentialsFormFieldState extends State<UserCredentialsFormField> {
),
),
ObscuredInputTextFormField(
key: const ValueKey('login-password'),
label: S.of(context).loginPagePasswordFieldLabel,
onChanged: (password) => field.didChange(
field.value?.copyWith(password: password) ??

View File

@@ -33,7 +33,7 @@ import 'package:paperless_mobile/generated/l10n.dart';
import 'package:paperless_mobile/util.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
void main() async {
Future<void> startAppProd() async {
Bloc.observer = BlocChangesObserver();
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
@@ -42,25 +42,22 @@ void main() async {
// Required for self signed client certificates
HttpOverrides.global = X509HttpOverrides();
configureDependencies();
configureDependencies('prod');
// Remove temporarily downloaded files.
(await FileService.temporaryDirectory).deleteSync(recursive: true);
kPackageInfo = await PackageInfo.fromPlatform();
// Load application settings and stored authentication data
getIt<ConnectivityCubit>().initialize();
await getIt<ConnectivityCubit>().initialize();
await getIt<ApplicationSettingsCubit>().initialize();
await getIt<AuthenticationCubit>().initialize();
// Preload asset images
// WARNING: This seems to bloat up the app up to almost 200mb!
// await Future.forEach<AssetImage>(
// AssetImages.values.map((e) => e.image),
// (img) => loadImage(img),
// );
runApp(const PaperlessMobileEntrypoint());
}
void main() async {
await startAppProd();
}
class PaperlessMobileEntrypoint extends StatefulWidget {
const PaperlessMobileEntrypoint({Key? key}) : super(key: key);
@@ -114,11 +111,7 @@ class _PaperlessMobileEntrypointState extends State<PaperlessMobileEntrypoint> {
),
),
themeMode: settings.preferredThemeMode,
supportedLocales: const [
Locale('en'), // Default if system locale is not available
Locale('de'),
Locale('cs'),
],
supportedLocales: S.delegate.supportedLocales,
locale: Locale.fromSubtags(
languageCode: settings.preferredLocaleSubtag),
localizationsDelegates: const [

View File

@@ -610,7 +610,7 @@ packages:
name: form_builder_validators
url: "https://pub.dartlang.org"
source: hosted
version: "8.3.0"
version: "8.4.0"
frontend_server_client:
dependency: transitive
description:

View File

@@ -62,7 +62,7 @@ dependencies:
git:
url: https://github.com/flutter-form-builder-ecosystem/form_builder_extra_fields.git
ref: main
form_builder_validators: ^8.3.0
form_builder_validators: ^8.4.0
infinite_scroll_pagination: ^3.2.0
sliding_up_panel: ^2.0.0+1
package_info_plus: ^1.4.3+1