diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1edb7d80b..72bdc3cf0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -122,7 +122,6 @@ jobs: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.MATCH_PASSWORD }} MATCH_GIT_PRIVATE_KEY: ${{ secrets.PASSWORDS_REPO_DEPLOY_KEY }} - THALIA_API_HOST: ${{ vars.THALIA_API_HOST }} THALIA_OAUTH_APP_ID: ${{ secrets.THALIA_OAUTH_APP_ID }} THALIA_OAUTH_APP_SECRET: ${{ secrets.THALIA_OAUTH_APP_SECRET }} TOSTI_API_HOST: ${{ vars.TOSTI_API_HOST }} @@ -198,7 +197,6 @@ jobs: - name: Build app env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - THALIA_API_HOST: ${{ vars.THALIA_API_HOST }} THALIA_OAUTH_APP_ID: ${{ secrets.THALIA_OAUTH_APP_ID }} THALIA_OAUTH_APP_SECRET: ${{ secrets.THALIA_OAUTH_APP_SECRET }} TOSTI_API_HOST: ${{ vars.TOSTI_API_HOST }} diff --git a/fastlane/Fastfile-Android b/fastlane/Fastfile-Android index bc2c07156..fe3301bc2 100644 --- a/fastlane/Fastfile-Android +++ b/fastlane/Fastfile-Android @@ -22,8 +22,7 @@ platform :android do build_cmd += " appbundle" end - if ENV["THALIA_OAUTH_APP_ID"] and ENV["THALIA_OAUTH_APP_SECRET"] and ENV["THALIA_API_HOST"] - build_cmd += " --dart-define=THALIA_API_HOST=\"#{ENV["THALIA_API_HOST"]}\"" + if ENV["THALIA_OAUTH_APP_ID"] and ENV["THALIA_OAUTH_APP_SECRET"] build_cmd += " --dart-define=THALIA_OAUTH_APP_ID=\"#{ENV["THALIA_OAUTH_APP_ID"]}\"" build_cmd += " --dart-define=THALIA_OAUTH_APP_SECRET=\"#{ENV["THALIA_OAUTH_APP_SECRET"]}\"" end diff --git a/fastlane/Fastfile-iOS b/fastlane/Fastfile-iOS index d39f8c987..ef73e15e4 100644 --- a/fastlane/Fastfile-iOS +++ b/fastlane/Fastfile-iOS @@ -33,8 +33,7 @@ platform :ios do sh("flutter clean") build_cmd = "flutter build ios --release --config-only" - if ENV["THALIA_OAUTH_APP_ID"] and ENV["THALIA_OAUTH_APP_SECRET"] and ENV["THALIA_API_HOST"] - build_cmd += " --dart-define=THALIA_API_HOST=\"#{ENV["THALIA_API_HOST"]}\"" + if ENV["THALIA_OAUTH_APP_ID"] and ENV["THALIA_OAUTH_APP_SECRET"] build_cmd += " --dart-define=THALIA_OAUTH_APP_ID=\"#{ENV["THALIA_OAUTH_APP_ID"]}\"" build_cmd += " --dart-define=THALIA_OAUTH_APP_SECRET=\"#{ENV["THALIA_OAUTH_APP_SECRET"]}\"" end diff --git a/integration_test/login_screen_test.dart b/integration_test/login_screen_test.dart index afd3f872e..29b3871f4 100644 --- a/integration_test/login_screen_test.dart +++ b/integration_test/login_screen_test.dart @@ -21,7 +21,7 @@ void testLogin() { when(authCubit.state).thenReturn(state); }) ..add(LoadingAuthState()) - ..add(LoggedOutAuthState()); + ..add(const LoggedOutAuthState()); when(authCubit.load()).thenAnswer((_) => Future.value(null)); when(authCubit.stream).thenAnswer((_) => streamController.stream); @@ -138,7 +138,7 @@ void testLogin() { verify(authCubit.logOut()).called(1); - streamController.add(LoggedOutAuthState()); + streamController.add(LoggedOutAuthState(apiRepository: api)); await tester.pumpAndSettle(); expect(find.text('LOGIN'), findsOneWidget); diff --git a/lib/api/api_repository.dart b/lib/api/api_repository.dart index 86da7ea66..0bf3a61bc 100644 --- a/lib/api/api_repository.dart +++ b/lib/api/api_repository.dart @@ -1,3 +1,4 @@ +import 'package:reaxit/config.dart'; import 'package:reaxit/models.dart'; import 'package:reaxit/api/exceptions.dart'; @@ -7,7 +8,9 @@ import 'package:reaxit/api/exceptions.dart'; /// In case credentials cannot be refreshed, this calls `logOut`, which should /// close the client and indicates that the user is no longer logged in. abstract class ApiRepository { - ApiRepository(); + final Config config; + + ApiRepository(this.config); /// Closes the connection to the api. This must be called when logging out. void close(); diff --git a/lib/api/concrexit_api_repository.dart b/lib/api/concrexit_api_repository.dart index ff79548db..cee636acf 100644 --- a/lib/api/concrexit_api_repository.dart +++ b/lib/api/concrexit_api_repository.dart @@ -7,7 +7,7 @@ import 'package:http_parser/http_parser.dart'; import 'package:oauth2/oauth2.dart' as oauth2; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; import 'package:reaxit/models.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -50,29 +50,51 @@ class LoggingClient extends oauth2.Client { /// In case credentials cannot be refreshed, this calls `logOut`, which should /// close the client and indicates that the user is no longer logged in. class ConcrexitApiRepository implements ApiRepository { - /// The [oauth2.Client] used to access the API. - final LoggingClient _client; + @override + final Config config; + + /// The authenticated client used to access the API. + LoggingClient? _innerClient; + final Function() _onLogOut; ConcrexitApiRepository({ - /// The [oauth2.Client] used to access the API. + /// The authenticated client used to access the API. required LoggingClient client, + /// An [Config] describing the API. + required this.config, + /// Called when the client can no longer authenticate. required Function() onLogOut, - }) : _client = client, - _onLogOut = onLogOut; + }) : _innerClient = client, + _onLogOut = onLogOut, + _baseUri = Uri( + scheme: config.scheme, + host: config.host, + port: config.port, + ); @override void close() { - _client.close(); + if (_innerClient != null) { + _innerClient!.close(); + _innerClient = null; + } + } + + /// The authenticated client used to access the API. + /// + /// Throws [ApiException.notLoggedIn] if the ApiRepository is not closed. + LoggingClient get _client { + if (_innerClient == null) { + throw ApiException.notLoggedIn; + } else { + return _innerClient!; + } } - static final Uri _baseUri = Uri( - scheme: config.apiScheme, - host: config.apiHost, - port: config.apiPort, - ); + final Uri _baseUri; static const String _basePath = 'api/v2'; @@ -82,7 +104,7 @@ class ConcrexitApiRepository implements ApiRepository { }; /// Convenience method for building a URL to an API endpoint. - static Uri _uri({required String path, Map? query}) { + Uri _uri({required String path, Map? query}) { return _baseUri.replace( path: path.startsWith('/') ? '$_basePath$path' : '$_basePath/$path', queryParameters: query, diff --git a/lib/blocs/album_list_cubit.dart b/lib/blocs/album_list_cubit.dart index d0c7b1de3..d5edb7a5f 100644 --- a/lib/blocs/album_list_cubit.dart +++ b/lib/blocs/album_list_cubit.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; import 'package:reaxit/blocs.dart'; import 'package:reaxit/models.dart'; @@ -112,7 +112,7 @@ class AlbumListCubit extends Cubit { /// Don't get results when the query is empty. emit(const AlbumListState.loading(results: [])); } else { - _searchDebounceTimer = Timer(config.searchDebounceTime, load); + _searchDebounceTimer = Timer(Config.searchDebounceTime, load); } } } diff --git a/lib/blocs/auth_cubit.dart b/lib/blocs/auth_cubit.dart index 8bb9d415e..e28408f06 100644 --- a/lib/blocs/auth_cubit.dart +++ b/lib/blocs/auth_cubit.dart @@ -12,7 +12,7 @@ import 'package:oauth2/oauth2.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/concrexit_api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -20,25 +20,20 @@ final _redirectUrl = Uri.parse( 'nu.thalia://callback', ); -final Uri _authorizationEndpoint = Uri( - scheme: config.apiScheme, - host: config.apiHost, - port: config.apiPort, - path: 'user/oauth/authorize/', -); - -final Uri _tokenEndpoint = Uri( - scheme: config.apiScheme, - host: config.apiHost, - port: config.apiPort, - path: 'user/oauth/token/', -); - const _credentialsStorageKey = 'ThaliApp OAuth2 credentials'; const _devicePkPreferenceKey = 'deviceRegistrationId'; -abstract class AuthState extends Equatable { +enum Environment { + staging, + production, + local; + + static const defaultEnvironment = + Config.production != null ? Environment.production : Environment.staging; +} + +class AuthState extends Equatable { const AuthState(); @override @@ -49,7 +44,21 @@ abstract class AuthState extends Equatable { class LoadingAuthState extends AuthState {} /// Not logged in. -class LoggedOutAuthState extends AuthState {} +class LoggedOutAuthState extends AuthState { + final Environment selectedEnvironment; + + /// A closed [ApiRepository] left over after logging out, that allows + /// keeping cubits alive while animating towards the login screen. + final ApiRepository? apiRepository; + + const LoggedOutAuthState({ + this.selectedEnvironment = Environment.defaultEnvironment, + this.apiRepository, + }); + + @override + List get props => [selectedEnvironment, apiRepository]; +} /// Logged in. class LoggedInAuthState extends AuthState { @@ -94,26 +103,38 @@ class AuthCubit extends Cubit { // Retrieve existing credentials. final stored = await storage.read( key: _credentialsStorageKey, - iOptions: - const IOSOptions(accessibility: KeychainAccessibility.first_unlock), + iOptions: const IOSOptions( + accessibility: KeychainAccessibility.first_unlock, + ), ); if (stored != null) { // Restore credentials from the storage. final credentials = Credentials.fromJson(stored); + final Config apiConfig; + if (Config.production != null && + credentials.tokenEndpoint == Config.production!.tokenEndpoint) { + apiConfig = Config.production!; + } else if (Config.local != null && + credentials.tokenEndpoint == Config.local!.tokenEndpoint) { + apiConfig = Config.local!; + } else { + apiConfig = Config.staging; + } + // Log out if not all required scopes are available. After an update that // introduces a new scope, this will cause the app to log out and get new // credentials with the required scopes, instead of just getting 403's // until you manually log out. final scopes = credentials.scopes?.toSet() ?? {}; - if (scopes.containsAll(config.oauthScopes)) { + if (scopes.containsAll(Config.oauthScopes)) { // Create the API repository. final apiRepository = ConcrexitApiRepository( client: LoggingClient( credentials, - identifier: config.apiIdentifier, - secret: config.apiSecret, + identifier: apiConfig.identifier, + secret: apiConfig.secret, onCredentialsRefreshed: (credentials) async { const storage = FlutterSecureStorage(); await storage.write( @@ -129,6 +150,7 @@ class AuthCubit extends Cubit { SentryStatusCode.range(405, 499), ]), ), + config: apiConfig, onLogOut: logOut, ); @@ -145,7 +167,7 @@ class AuthCubit extends Cubit { } else { // Clear username for sentry. Sentry.configureScope((scope) => scope.setUser(null)); - emit(LoggedOutAuthState()); + emit(const LoggedOutAuthState()); } } on PlatformException { try { @@ -158,7 +180,7 @@ class AuthCubit extends Cubit { } on PlatformException { // Ignore. } - emit(LoggedOutAuthState()); + emit(const LoggedOutAuthState()); } } @@ -169,15 +191,21 @@ class AuthCubit extends Cubit { /// authenticated client to make requests to the API. Furthermore, it will /// handle whatever need to happen upon loggin in, such as setting up push /// notifications. - Future logIn() async { + Future logIn(Environment environment) async { emit(LoadingAuthState()); + final apiConfig = switch (environment) { + Environment.staging => Config.staging, + Environment.production => Config.production ?? Config.staging, + Environment.local => Config.local ?? Config.staging, + }; + // Prepare for the authentication flow. final grant = AuthorizationCodeGrant( - config.apiIdentifier, - _authorizationEndpoint, - _tokenEndpoint, - secret: config.apiSecret, + apiConfig.identifier, + apiConfig.authorizationEndpoint, + apiConfig.tokenEndpoint, + secret: apiConfig.secret, onCredentialsRefreshed: (credentials) async { // When credentials are refreshed, store them. const storage = FlutterSecureStorage(); @@ -193,7 +221,7 @@ class AuthCubit extends Cubit { final authorizeUrl = grant.getAuthorizationUrl( _redirectUrl, - scopes: config.oauthScopes, + scopes: Config.oauthScopes, ); try { @@ -222,6 +250,7 @@ class AuthCubit extends Cubit { final apiRepository = ConcrexitApiRepository( client: LoggingClient.fromClient(client), + config: apiConfig, onLogOut: logOut, ); @@ -231,12 +260,16 @@ class AuthCubit extends Cubit { } on PlatformException catch (exception) { // Forward exceptions from the authentication flow. emit(FailureAuthState(message: exception.message)); + emit(LoggedOutAuthState(selectedEnvironment: environment)); } on SocketException catch (_) { emit(const FailureAuthState(message: 'No internet.')); + emit(LoggedOutAuthState(selectedEnvironment: environment)); } on AuthorizationException catch (_) { emit(const FailureAuthState(message: 'Authorization failed.')); + emit(LoggedOutAuthState(selectedEnvironment: environment)); } catch (_) { emit(const FailureAuthState(message: 'An unknown error occurred.')); + emit(LoggedOutAuthState(selectedEnvironment: environment)); } } @@ -262,7 +295,21 @@ class AuthCubit extends Cubit { // Clear username for sentry. Sentry.configureScope((scope) => scope.setUser(null)); - emit(LoggedOutAuthState()); + + if (state is LoggedInAuthState) { + emit(LoggedOutAuthState(apiRepository: state.apiRepository)); + } + } + + /// Change the selected environment when on the login screen. + void selectEnvironment(Environment environment) { + final state = this.state; + if (state is LoggedOutAuthState) { + emit(LoggedOutAuthState( + selectedEnvironment: environment, + apiRepository: state.apiRepository, + )); + } } /// Set up push notifications. diff --git a/lib/blocs/calendar_cubit.dart b/lib/blocs/calendar_cubit.dart index 969ae2eea..9b5b6a05e 100644 --- a/lib/blocs/calendar_cubit.dart +++ b/lib/blocs/calendar_cubit.dart @@ -7,7 +7,7 @@ import 'package:intl/intl.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; import 'package:reaxit/blocs.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; import 'package:reaxit/models.dart'; /// Wrapper around a [BaseEvent] to be shown in the calendar. @@ -505,7 +505,7 @@ class CalendarCubit extends Cubit { emit(CalendarState(_truthTime, const DoubleListState.success(isDoneUp: true, isDoneDown: true))); } else { - _searchDebounceTimer = Timer(config.searchDebounceTime, load); + _searchDebounceTimer = Timer(Config.searchDebounceTime, load); } } } diff --git a/lib/blocs/event_admin_cubit.dart b/lib/blocs/event_admin_cubit.dart index 2da5ae8fa..6a5072dcf 100644 --- a/lib/blocs/event_admin_cubit.dart +++ b/lib/blocs/event_admin_cubit.dart @@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:meta/meta.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; import 'package:reaxit/models.dart'; class EventAdminState extends Equatable { @@ -183,7 +183,7 @@ class EventAdminCubit extends Cubit { )); } else { _searchDebounceTimer = Timer( - config.searchDebounceTime, + Config.searchDebounceTime, loadRegistrations, ); } diff --git a/lib/blocs/food_admin_cubit.dart b/lib/blocs/food_admin_cubit.dart index 6debf8362..3543b6fb8 100644 --- a/lib/blocs/food_admin_cubit.dart +++ b/lib/blocs/food_admin_cubit.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; import 'package:reaxit/blocs.dart'; import 'package:reaxit/models.dart'; @@ -66,7 +66,7 @@ class FoodAdminCubit extends Cubit { /// Don't get results when the query is empty. emit(const LoadingState()); } else { - _searchDebounceTimer = Timer(config.searchDebounceTime, load); + _searchDebounceTimer = Timer(Config.searchDebounceTime, load); } } } diff --git a/lib/blocs/groups_cubit.dart b/lib/blocs/groups_cubit.dart index 4242b61b0..0d0d7fa7b 100644 --- a/lib/blocs/groups_cubit.dart +++ b/lib/blocs/groups_cubit.dart @@ -5,7 +5,7 @@ import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; import 'package:reaxit/blocs.dart'; import 'package:reaxit/models.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; typedef GroupsState = DetailState>; @@ -92,7 +92,7 @@ class AllGroupsCubit extends GroupsCubit { // Don't get results when the query is empty. emit(const LoadingState()); } else { - _searchDebounceTimer = Timer(config.searchDebounceTime, load); + _searchDebounceTimer = Timer(Config.searchDebounceTime, load); } } } diff --git a/lib/blocs/member_list_cubit.dart b/lib/blocs/member_list_cubit.dart index bebd06074..0cc360d6c 100644 --- a/lib/blocs/member_list_cubit.dart +++ b/lib/blocs/member_list_cubit.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; import 'package:reaxit/blocs.dart'; import 'package:reaxit/models.dart'; @@ -112,7 +112,7 @@ class MemberListCubit extends Cubit { /// Don't get results when the query is empty. emit(const MemberListState.loading(results: [])); } else { - _searchDebounceTimer = Timer(config.searchDebounceTime, load); + _searchDebounceTimer = Timer(Config.searchDebounceTime, load); } } } diff --git a/lib/config.dart b/lib/config.dart index 0302d5975..68a3d2564 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -1,35 +1,18 @@ +import 'package:flutter/widgets.dart'; + /// This file specifies configuration options that can be passed /// at compile time through `--dart-define`s, as is done by fastlane. /// -/// The default values can be used on the staging server. If some variables are -/// specified, be sure to specify all. +/// By default, you can sign in to staging. If THALIA_OAUTH_APP_SECRET and +/// THALIA_OAUTH_APP_ID are provided, you sign in to production by default. +/// You can make it possible to sign in to a custom server by providing +/// LOCAL_THALIA_OAUTH_APP_SECRET and LOCAL_THALIA_OAUTH_APP_ID, and optionally +/// LOCAL_THALIA_API_HOST, LOCAL_THALIA_API_SCHEME and LOCAL_THALIA_API_PORT. /// /// If the `tostiXXX` variables are not defined, the T.O.S.T.I. API will not be /// used, and the corresponding UI will not be shown. This is because there is /// no staging T.O.S.T.I. server. /// The `tostiApiScheme` and `tostiApiPort` can be used for testing locally. -const String apiHost = String.fromEnvironment( - 'THALIA_API_HOST', - defaultValue: 'staging.thalia.nu', -); - -const String apiHostCDN = 'cdn.$apiHost'; - -const String apiSecret = String.fromEnvironment( - 'THALIA_OAUTH_APP_SECRET', - defaultValue: - 'Chwh1BE3MgfU1OZZmYRV3LU3e3GzpZJ6tiWrqzFY3dPhMlS7VYD3qMm1RC1pPBvg' - '3WaWmJxfRq8bv5ElVOpjRZwabAGOZ0DbuHhW3chAMaNlOmwXixNfUJIKIBzlnr7I', -); - -const String apiIdentifier = String.fromEnvironment( - 'THALIA_OAUTH_APP_ID', - defaultValue: '3zlt7pqGVMiUCGxOnKTZEpytDUN7haeFBP2kVkig', -); - -const String apiScheme = - String.fromEnvironment('THALIA_API_SCHEME', defaultValue: 'https'); -const int apiPort = int.fromEnvironment('THALIA_API_PORT', defaultValue: 443); const String sentryDSN = String.fromEnvironment('SENTRY_DSN'); @@ -53,56 +36,147 @@ const List tostiOauthScopes = [ 'thaliedje:manage', ]; -const List oauthScopes = [ - 'read', - 'write', - 'activemembers:read', - 'announcements:read', - 'events:read', - 'events:register', - 'events:admin', - 'food:read', - 'food:order', - 'food:admin', - 'members:read', - 'photos:read', - 'profile:read', - 'profile:write', - 'pushnotifications:read', - 'pushnotifications:write', - 'payments:read', - 'payments:write', - 'payments:admin', - 'partners:read', - 'sales:read', - 'sales:order', -]; - -const Duration searchDebounceTime = Duration(milliseconds: 200); - -const String versionNumber = 'v3.5.0'; - -final Uri feedbackUri = Uri.parse( - 'https://github.com/svthalia/Reaxit/issues', -); - -final Uri changelogUri = Uri.parse( - 'https://github.com/svthalia/Reaxit/releases', -); - -final Uri termsAndConditionsUrl = Uri.parse( - 'https://$apiHost/event-registration-terms/', -); - -final Uri tpaySignDirectDebitMandateUrl = Uri.parse( - 'https://$apiHost/user/finance/accounts/add/', -); - -/// The period after which objects are removed from the cache when not used. -const Duration cacheStalePeriod = Duration(days: 30); - -/// The maximum number of objects in the cache. -/// -/// Assuming most cached images are 'small' (300x300), the -/// storage used will be +- 20KB * [cacheMaxObjects]. -const int cacheMaxObjects = 2000; +class Config { + final String host; + final String secret; + final String identifier; + final String scheme; + final int port; + + const Config({ + required this.host, + required this.secret, + required this.identifier, + required this.scheme, + required this.port, + }); + + /// The period after which objects are removed from the cache when not used. + static const Duration cacheStalePeriod = Duration(days: 30); + + /// The maximum number of objects in the cache. + /// + /// Assuming most cached images are 'small' (300x300), the + /// storage used will be +- 20KB * [cacheMaxObjects]. + static const int cacheMaxObjects = 2000; + + String get cdn => 'cdn.$host'; + Uri get authorizationEndpoint => Uri( + scheme: scheme, + host: host, + port: port, + path: 'user/oauth/authorize/', + ); + + Uri get tokenEndpoint => Uri( + scheme: scheme, + host: host, + port: port, + path: 'user/oauth/token/', + ); + + static Uri feedbackUri = Uri.parse( + 'https://github.com/svthalia/Reaxit/issues', + ); + + static Uri changelogUri = Uri.parse( + 'https://github.com/svthalia/Reaxit/releases', + ); + + Uri get termsAndConditionsUrl => Uri.parse( + 'https://$host/event-registration-terms/', + ); + + Uri get tpaySignDirectDebitMandateUrl => Uri.parse( + 'https://$host/user/finance/accounts/add/', + ); + + static const List oauthScopes = [ + 'read', + 'write', + 'activemembers:read', + 'announcements:read', + 'events:read', + 'events:register', + 'events:admin', + 'food:read', + 'food:order', + 'food:admin', + 'members:read', + 'photos:read', + 'profile:read', + 'profile:write', + 'pushnotifications:read', + 'pushnotifications:write', + 'payments:read', + 'payments:write', + 'payments:admin', + 'partners:read', + 'sales:read', + 'sales:order', + ]; + + static const Duration searchDebounceTime = Duration(milliseconds: 200); + + static const String versionNumber = 'v3.5.0'; + + static const Config defaultConfig = Config.production ?? Config.staging; + + static const Config staging = Config( + host: 'staging.thalia.nu', + secret: 'Chwh1BE3MgfU1OZZmYRV3LU3e3GzpZJ6tiWrqzFY3dPhMlS7VYD3qMm1RC1pPBvg' + '3WaWmJxfRq8bv5ElVOpjRZwabAGOZ0DbuHhW3chAMaNlOmwXixNfUJIKIBzlnr7I', + identifier: '3zlt7pqGVMiUCGxOnKTZEpytDUN7haeFBP2kVkig', + scheme: 'https', + port: 443, + ); + + static const Config? production = + (bool.hasEnvironment('THALIA_OAUTH_APP_SECRET') && + bool.hasEnvironment('THALIA_OAUTH_APP_ID')) + ? Config( + host: 'thalia.nu', + secret: String.fromEnvironment('THALIA_OAUTH_APP_SECRET'), + identifier: String.fromEnvironment('THALIA_OAUTH_APP_ID'), + scheme: 'https', + port: 443, + ) + : null; + + static const Config? local = + (bool.hasEnvironment('LOCAL_THALIA_OAUTH_APP_SECRET') && + bool.hasEnvironment('LOCAL_THALIA_OAUTH_APP_ID')) + ? Config( + host: String.fromEnvironment( + 'LOCAL_THALIA_API_HOST', + defaultValue: '127.0.0.1', + ), + secret: String.fromEnvironment('LOCAL_THALIA_OAUTH_APP_SECRET'), + identifier: String.fromEnvironment('LOCAL_THALIA_OAUTH_APP_ID'), + scheme: String.fromEnvironment( + 'LOCAL_THALIA_API_SCHEME', + defaultValue: 'http', + ), + port: int.fromEnvironment( + 'LOCAL_THALIA_API_PORT', + defaultValue: 8000, + ), + ) + : null; + + static Config of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()!.config; +} + +class InheritedConfig extends InheritedWidget { + final Config config; + + const InheritedConfig({ + required this.config, + required Widget child, + }) : super(child: child); + + @override + bool updateShouldNotify(covariant InheritedConfig oldWidget) => + config != oldWidget.config; +} diff --git a/lib/main.dart b/lib/main.dart index b2907e8c4..d8d7c534e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,12 +8,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:overlay_support/overlay_support.dart'; +import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/blocs.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; import 'package:reaxit/firebase_options.dart'; import 'package:reaxit/routes.dart'; import 'package:reaxit/tosti/blocs/auth_cubit.dart'; -import 'package:reaxit/ui/screens.dart'; import 'package:reaxit/ui/theme.dart'; import 'package:reaxit/ui/widgets.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -42,7 +42,7 @@ Future main() async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await SentryFlutter.init( (options) { - options.dsn = config.sentryDSN; + options.dsn = sentryDSN; }, appRunner: () async { runApp(BlocProvider( @@ -203,10 +203,16 @@ class _ThaliAppState extends State { // logged in. If the user is logged in, and there is an original // path in the query parameters, redirect to that original path. redirect: (context, state) { - final loggedIn = _authCubit.state is LoggedInAuthState; + final authState = _authCubit.state; + final loggedIn = authState is LoggedInAuthState; + final justLoggedOut = + authState is LoggedOutAuthState && authState.apiRepository != null; final goingToLogin = state.location.startsWith('/login'); if (!loggedIn && !goingToLogin) { + // Drop original location if you just logged out. + if (justLoggedOut) return '/login'; + return Uri(path: '/login', queryParameters: { 'from': state.location, }).toString(); @@ -276,93 +282,91 @@ class _ThaliAppState extends State { )); } }, - + buildWhen: (previous, current) => current is! FailureAuthState, builder: (context, authState) { - // Build with cubits provided when logged in. - if (authState is LoggedInAuthState) { - return RepositoryProvider.value( - value: authState.apiRepository, - child: MultiBlocProvider( - providers: [ - BlocProvider( - create: (_) => PaymentUserCubit( - authState.apiRepository, - )..load(), - lazy: false, - ), - BlocProvider( - create: (_) => FullMemberCubit( - authState.apiRepository, - )..load(), - lazy: false, - ), - BlocProvider( - create: (_) => WelcomeCubit( - authState.apiRepository, - )..load(), - lazy: false, - ), - BlocProvider( - create: (_) => CalendarCubit( - authState.apiRepository, - )..cachedLoad(), - lazy: false, - ), - BlocProvider( - create: (_) => MemberListCubit( - authState.apiRepository, - )..load(), - lazy: false, - ), - BlocProvider( - create: (_) => AlbumListCubit( - authState.apiRepository, - )..load(), - lazy: false, - ), - BlocProvider( - // The SettingsCubit must not be lazy, since - // it handles setting up push notifications. - create: (_) => SettingsCubit( - authState.apiRepository, - )..load(), - lazy: false, - ), - BlocProvider( - create: (_) => TostiAuthCubit()..load(), - lazy: true, - ), - BlocProvider( - create: (_) => BoardsCubit( - authState.apiRepository, - )..load(), - lazy: true, - ), - BlocProvider( - create: (_) => CommitteesCubit( - authState.apiRepository, - )..load(), - lazy: true, - ), - BlocProvider( - create: (_) => SocietiesCubit( - authState.apiRepository, - )..load(), - lazy: true, - ), - ], - child: navigator!, + // Build with ApiRepository and cubits provided when an + // ApiRepository is available. This is the case when logged + // in, but also when just logged out (after having been logged + // in), with a closed ApiRepository. + // The latter allows us to keep the cubits alive + // while animating towards the login screen. + if (authState is LoggedInAuthState || + (authState is LoggedOutAuthState && + authState.apiRepository != null)) { + final ApiRepository apiRepository; + if (authState is LoggedInAuthState) { + apiRepository = authState.apiRepository; + } else { + apiRepository = + (authState as LoggedOutAuthState).apiRepository!; + } + + return InheritedConfig( + config: apiRepository.config, + child: RepositoryProvider.value( + value: apiRepository, + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => + PaymentUserCubit(apiRepository)..load(), + lazy: false, + ), + BlocProvider( + create: (_) => + FullMemberCubit(apiRepository)..load(), + lazy: false, + ), + BlocProvider( + create: (_) => + WelcomeCubit(apiRepository)..load(), + lazy: false, + ), + BlocProvider( + create: (_) => + CalendarCubit(apiRepository)..cachedLoad(), + lazy: false, + ), + BlocProvider( + create: (_) => + MemberListCubit(apiRepository)..load(), + lazy: false, + ), + BlocProvider( + create: (_) => + AlbumListCubit(apiRepository)..load(), + lazy: false, + ), + BlocProvider( + // The SettingsCubit must not be lazy, since + // it handles setting up push notifications. + create: (_) => + SettingsCubit(apiRepository)..load(), + lazy: false, + ), + BlocProvider( + create: (_) => TostiAuthCubit()..load(), + lazy: true, + ), + BlocProvider( + create: (_) => BoardsCubit(apiRepository)..load(), + lazy: true, + ), + BlocProvider( + create: (_) => + CommitteesCubit(apiRepository)..load(), + lazy: true, + ), + BlocProvider( + create: (_) => + SocietiesCubit(apiRepository)..load(), + lazy: true, + ), + ], + child: navigator!, + ), ), ); - } else if (authState is LoggedOutAuthState) { - // Don't show the navigator (which is animating from a logged-in - // stack towards only the login screen). This prevents getting - // `ProviderNotFoundException`s during the animation, caused by - // the cubits no longer being provided after logging out. - // There is no transition shown when logging out, because - // there is temporarily no navigator rendering an animation. - return const LoginScreen(); - // TODO: This is hacky. There should be a neat way to handle this. } else { return navigator!; } diff --git a/lib/models/event.dart b/lib/models/event.dart index b1b974bfb..6f4effc53 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -1,6 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:reaxit/models.dart'; -import 'package:reaxit/config.dart' as config; part 'event.g.dart'; @@ -205,11 +204,6 @@ class AdminEvent implements BaseEvent { // final Uri markPresentUrl; final String markPresentUrlToken; - Uri get markPresentUrl => Uri( - scheme: 'https', - host: config.apiHost, - path: '/events/$pk/mark-present/$markPresentUrlToken', - ); bool get registrationIsRequired => registrationStart != null || registrationEnd != null; diff --git a/lib/routes.dart b/lib/routes.dart index ab745e8fc..6933cc16c 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -11,14 +11,17 @@ import 'package:reaxit/ui/widgets.dart'; /// Returns true if [uri] is a deep link that can be handled by the app. bool isDeepLink(Uri uri) { - if (uri.host != config.apiHost) return false; - return _deepLinkRegExps.any((re) => re.hasMatch(uri.path)); + if (uri.host case 'thalia.nu' || 'staging.thalia.nu') { + return _deepLinkRegExps.any((re) => re.hasMatch(uri.path)); + } + return false; } const _uuid = '([a-z0-9]{8}-([a-z0-9]{4}-){3}[a-z0-9]{12})'; -/* Any route added here also needs to be added to -android/app/src/main/AndroidManifest.xml and android/app/src/debug/AndroidManifest.xml */ +// Any route added here also needs to be added to +// android/app/src/main/AndroidManifest.xml and +// android/app/src/debug/AndroidManifest.xml /// The [RegExp]s that can used as deep links. This list should /// contain all deep links that should be handled by the app. diff --git a/lib/ui/screens/album_screen.dart b/lib/ui/screens/album_screen.dart index 8bbcc12c4..7982b7149 100644 --- a/lib/ui/screens/album_screen.dart +++ b/lib/ui/screens/album_screen.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reaxit/blocs.dart'; import 'package:reaxit/api/api_repository.dart'; +import 'package:reaxit/config.dart'; import 'package:reaxit/models.dart'; import 'package:reaxit/ui/widgets.dart'; -import 'package:reaxit/config.dart' as config; import 'package:reaxit/ui/widgets/gallery.dart'; import 'package:reaxit/ui/widgets/photo_tile.dart'; import 'package:share_plus/share_plus.dart'; @@ -35,8 +35,9 @@ class _AlbumScreenState extends State { Future _shareAlbum(BuildContext context) async { final messenger = ScaffoldMessenger.of(context); try { + final host = Config.of(context).host; await Share.share( - 'https://${config.apiHost}/members/photos/${widget.slug}/', + 'https://$host/members/photos/${widget.slug}/', ); } catch (_) { messenger.showSnackBar( diff --git a/lib/ui/screens/event_admin_screen.dart b/lib/ui/screens/event_admin_screen.dart index e7527c9b0..b42cde528 100644 --- a/lib/ui/screens/event_admin_screen.dart +++ b/lib/ui/screens/event_admin_screen.dart @@ -5,6 +5,7 @@ import 'package:qr_flutter/qr_flutter.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; import 'package:reaxit/blocs.dart'; +import 'package:reaxit/config.dart'; import 'package:reaxit/models.dart'; import 'package:reaxit/ui/widgets.dart'; @@ -129,6 +130,10 @@ class _MarkPresentQrButton extends StatelessWidget { builder: (context, state) { final theme = Theme.of(context); if (state.event != null) { + final host = Config.of(context).host; + final pk = state.event!.pk; + final token = state.event!.markPresentUrlToken; + final url = 'https://$host/events/$pk/mark-present/$token'; return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -140,7 +145,7 @@ class _MarkPresentQrButton extends StatelessWidget { ), ), QrImage( - data: state.event!.markPresentUrl.toString(), + data: url, padding: const EdgeInsets.all(24), backgroundColor: Colors.grey[50]!, ), diff --git a/lib/ui/screens/event_screen.dart b/lib/ui/screens/event_screen.dart index 672e7861f..6255482c0 100644 --- a/lib/ui/screens/event_screen.dart +++ b/lib/ui/screens/event_screen.dart @@ -14,7 +14,7 @@ import 'package:reaxit/ui/widgets.dart'; import 'package:reaxit/ui/widgets/file_button.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; class EventScreen extends StatefulWidget { final String? slug; @@ -761,7 +761,7 @@ class _EventScreenState extends State { } TextSpan _makeTermsAndConditions(Event event) { - final url = config.termsAndConditionsUrl; + final url = Config.of(context).termsAndConditionsUrl; return TextSpan( children: [ const TextSpan( diff --git a/lib/ui/screens/login_screen.dart b/lib/ui/screens/login_screen.dart index efe7a202b..eb50dfee7 100644 --- a/lib/ui/screens/login_screen.dart +++ b/lib/ui/screens/login_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reaxit/blocs.dart'; +import 'package:reaxit/config.dart'; import 'package:reaxit/ui/theme.dart'; class LoginScreen extends StatefulWidget { @@ -23,62 +24,74 @@ class _LoginScreenState extends State { Widget build(BuildContext context) { return BlocBuilder( buildWhen: (previous, current) => - current is LoggedOutAuthState || - current is LoadingAuthState || - current is FailureAuthState, + current is LoggedOutAuthState || current is LoadingAuthState, builder: (context, authState) { - if (authState is LoadingAuthState) { + if (authState is LoggedOutAuthState) { return Scaffold( - backgroundColor: const Color(0xFFE62272), - body: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Image( - image: logo, - width: 260, - ), - ), - const SizedBox(height: 50), - const SizedBox( - height: 50, - child: Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), + backgroundColor: magenta, + body: SafeArea( + child: Stack( + children: [ + Align( + alignment: Alignment.topRight, + child: IconButton( + color: Colors.white54, + padding: const EdgeInsets.all(16), + icon: const Icon(Icons.build_rounded), + tooltip: + "Select environment, you probably don't need this", + onPressed: () { + showDialog( + context: context, + builder: (context) => const SelectEnvironmentDialog(), + ); + }, ), ), - ), - ], + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Center(child: Image(image: logo, width: 260)), + const SizedBox(height: 50), + SizedBox( + height: 50, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black87, + foregroundColor: Colors.white, + ), + onPressed: () { + BlocProvider.of(context) + .logIn(authState.selectedEnvironment); + }, + child: Text(switch (authState.selectedEnvironment) { + Environment.production => 'LOG IN', + Environment.staging => 'LOG IN - STAGING', + Environment.local => 'LOG IN - LOCAL', + }), + ), + ), + ], + ), + ], + ), ), ); } else { return Scaffold( - backgroundColor: magenta, + backgroundColor: const Color(0xFFE62272), body: Column( mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: Image( - image: logo, - width: 260, - ), - ), + Center(child: Image(image: logo, width: 260)), const SizedBox(height: 50), - SizedBox( + const SizedBox( height: 50, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.black87, - foregroundColor: Colors.white, + child: Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), ), - onPressed: () { - BlocProvider.of( - context, - listen: false, - ).logIn(); - }, - child: const Text('LOGIN'), ), ), ], @@ -89,3 +102,76 @@ class _LoginScreenState extends State { ); } } + +class SelectEnvironmentDialog extends StatelessWidget { + const SelectEnvironmentDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('SELECT ENVIRONMENT'), + content: BlocBuilder( + buildWhen: (previous, current) => current is LoggedOutAuthState, + builder: (context, state) { + final selectedEnvironment = state is LoggedOutAuthState + ? state.selectedEnvironment + : Environment.defaultEnvironment; + return Column(mainAxisSize: MainAxisSize.min, children: [ + Text( + 'Select an alternative server to log in to. ' + 'If you are not sure you need this, just use production.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + const Divider(height: 0), + if (Config.production != null) + RadioListTile( + title: const Text('PRODUCTION'), + subtitle: const Text('Default: thalia.nu'), + value: Environment.production, + groupValue: selectedEnvironment, + onChanged: (environment) { + BlocProvider.of(context).selectEnvironment( + Environment.production, + ); + }, + ), + RadioListTile( + title: const Text('STAGING'), + value: Environment.staging, + subtitle: const Text( + 'Used by the Technicie for testing: staging.thalia.nu', + ), + groupValue: selectedEnvironment, + onChanged: (environment) { + BlocProvider.of(context).selectEnvironment( + Environment.staging, + ); + }, + ), + if (Config.local != null) + RadioListTile( + title: const Text('LOCAL'), + subtitle: const Text('You should know what you are doing.'), + value: Environment.local, + groupValue: selectedEnvironment, + onChanged: (environment) { + BlocProvider.of(context).selectEnvironment( + Environment.local, + ); + }, + ), + ]); + }, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('CLOSE'), + ) + ], + ); + } +} diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart index b077d3978..26e27767e 100644 --- a/lib/ui/screens/settings_screen.dart +++ b/lib/ui/screens/settings_screen.dart @@ -4,7 +4,7 @@ import 'package:reaxit/api/exceptions.dart'; import 'package:reaxit/blocs.dart'; import 'package:reaxit/models.dart'; import 'package:reaxit/ui/widgets.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; import 'package:url_launcher/url_launcher.dart'; class SettingsScreen extends StatelessWidget { @@ -287,7 +287,7 @@ class _AboutCard extends StatelessWidget { style: Theme.of(context).textTheme.headlineSmall, ), Text( - config.versionNumber, + Config.versionNumber, style: Theme.of(context).textTheme.bodyMedium, ), ], @@ -305,7 +305,7 @@ class _AboutCard extends StatelessWidget { OutlinedButton.icon( onPressed: () async { await launchUrl( - config.changelogUri, + Config.changelogUri, mode: LaunchMode.externalApplication, ); }, @@ -315,7 +315,7 @@ class _AboutCard extends StatelessWidget { OutlinedButton.icon( onPressed: () async { await launchUrl( - config.feedbackUri, + Config.feedbackUri, mode: LaunchMode.externalApplication, ); }, @@ -325,7 +325,7 @@ class _AboutCard extends StatelessWidget { OutlinedButton.icon( onPressed: () => showLicensePage( context: context, - applicationVersion: config.versionNumber, + applicationVersion: Config.versionNumber, applicationIcon: Builder(builder: (context) { return Image.asset( Theme.of(context).brightness == Brightness.light diff --git a/lib/ui/widgets/cached_image.dart b/lib/ui/widgets/cached_image.dart index 4d1acead4..c3175ebfa 100644 --- a/lib/ui/widgets/cached_image.dart +++ b/lib/ui/widgets/cached_image.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/widgets.dart'; import 'package:reaxit/utilities/cache_manager.dart' as cache; -import 'package:reaxit/config.dart' as config; /// Wrapper for [CachedNetworkImage] with sensible defaults. class CachedImage extends CachedNetworkImage { @@ -16,19 +15,7 @@ class CachedImage extends CachedNetworkImage { key: ValueKey(imageUrl), imageUrl: imageUrl, cacheManager: cache.ThaliaCacheManager(), - - /// If the image is from thalia.nu, remove the query part of the url - /// from its key in the cache. Private images from concrexit have a - /// signature in the url that expires every 3 hours. Removing this - /// signature makes sure that the same cache object can be used - /// regardless of the signature. This assumes that the qurey part is - /// only used for authentication, not to identify the image, so the - /// remaining path is a unique key. - /// If the url is not from thalia.nu, use the full url as the key. - cacheKey: (Uri.parse(imageUrl).host == config.apiHost || - Uri.parse(imageUrl).host == config.apiHostCDN) - ? Uri.parse(imageUrl).replace(query: '').toString() - : imageUrl, + cacheKey: _getCacheKey(imageUrl), fit: fit, fadeOutDuration: fadeOutDuration, fadeInDuration: fadeInDuration, @@ -42,6 +29,27 @@ class CachedImageProvider extends CachedNetworkImageProvider { : super( imageUrl, cacheManager: cache.ThaliaCacheManager(), - cacheKey: Uri.parse(imageUrl).replace(query: '').toString(), + cacheKey: _getCacheKey(imageUrl), ); } + +/// If the image is from thalia.nu, remove the query part of the url from its +/// key in the cache. Private images from concrexit have a signature in the url +/// that expires every few hours. Removing this signature makes sure that the +/// same cache object can be used regardless of the signature. +/// +/// This assumes that the query part is only used for authentication, +/// not to identify the image, so the remaining path is a unique key. +/// +/// If the url is not from thalia.nu, use the full url as the key. +String _getCacheKey(String url) { + final uri = Uri.parse(url); + if (uri.host + case 'thalia.nu' || + 'staging.thalia.nu' || + 'cdn.thalia.nu' || + 'cdn.staging.thalia.nu') { + return uri.replace(query: '').toString(); + } + return url; +} diff --git a/lib/ui/widgets/tpay_button.dart b/lib/ui/widgets/tpay_button.dart index 17edcee6c..0f2ba9095 100644 --- a/lib/ui/widgets/tpay_button.dart +++ b/lib/ui/widgets/tpay_button.dart @@ -4,7 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reaxit/api/exceptions.dart'; import 'package:reaxit/blocs.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:reaxit/config.dart'; class TPayButton extends StatefulWidget { /// A function that performs the payment. If null, the button is disabled. @@ -141,7 +141,7 @@ class _TPayButtonState extends State { ); } else if (!paymentUser.tpayEnabled) { // TPay not yet enabled. - final url = config.tpaySignDirectDebitMandateUrl; + final url = Config.of(context).tpaySignDirectDebitMandateUrl; final message = TextSpan( children: [ const TextSpan( diff --git a/lib/utilities/cache_manager.dart b/lib/utilities/cache_manager.dart index 3da7c9b8c..27055da91 100644 --- a/lib/utilities/cache_manager.dart +++ b/lib/utilities/cache_manager.dart @@ -1,17 +1,18 @@ -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:reaxit/config.dart' as config; +import 'package:flutter_cache_manager/flutter_cache_manager.dart' as cache; +import 'package:reaxit/config.dart'; /// A [BaseCacheManager] with customized configurations. -class ThaliaCacheManager extends CacheManager with ImageCacheManager { +class ThaliaCacheManager extends cache.CacheManager + with cache.ImageCacheManager { static const key = 'thaliaCachedData'; static final ThaliaCacheManager _instance = ThaliaCacheManager._(); factory ThaliaCacheManager() => _instance; ThaliaCacheManager._() - : super(Config( + : super(cache.Config( key, - stalePeriod: config.cacheStalePeriod, - maxNrOfCacheObjects: config.cacheMaxObjects, + stalePeriod: Config.cacheStalePeriod, + maxNrOfCacheObjects: Config.cacheMaxObjects, )); } diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 84abdced2..8aa166a67 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -254,10 +254,10 @@ class MockAuthCubit extends _i1.Mock implements _i2.AuthCubit { returnValueForMissingStub: _i7.Future.value(), ) as _i7.Future); @override - _i7.Future logIn() => (super.noSuchMethod( + _i7.Future logIn(_i2.Environment? environment) => (super.noSuchMethod( Invocation.method( #logIn, - [], + [environment], ), returnValue: _i7.Future.value(), returnValueForMissingStub: _i7.Future.value(), @@ -272,6 +272,14 @@ class MockAuthCubit extends _i1.Mock implements _i2.AuthCubit { returnValueForMissingStub: _i7.Future.value(), ) as _i7.Future); @override + void selectEnvironment(_i2.Environment? environment) => super.noSuchMethod( + Invocation.method( + #selectEnvironment, + [environment], + ), + returnValueForMissingStub: null, + ); + @override void emit(_i2.AuthState? state) => super.noSuchMethod( Invocation.method( #emit, diff --git a/test/unit/routes_test.dart b/test/unit/routes_test.dart index 977c301af..90ef2a250 100644 --- a/test/unit/routes_test.dart +++ b/test/unit/routes_test.dart @@ -1,11 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:reaxit/routes.dart'; -import 'package:reaxit/config.dart' as config; void main() { group('isDeepLink', () { test('returns true if uri is a deep link', () { - const apiHost = config.apiHost; + const apiHost = 'thalia.nu'; final validUris = [ 'https://$apiHost/events/1/', 'https://$apiHost/events/1', @@ -28,7 +27,7 @@ void main() { }); test('returns false if uri is not a deep link', () { - const apiHost = config.apiHost; + const apiHost = 'thalia.nu'; final invalidUris = [ 'https://$apiHost/contact', 'https://example.org/events/1/', diff --git a/test/widget/calendar_test.dart b/test/widget/calendar_test.dart index 82de77b22..32c0bcead 100644 --- a/test/widget/calendar_test.dart +++ b/test/widget/calendar_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:reaxit/blocs.dart'; +import 'package:reaxit/config.dart'; import 'package:reaxit/ui/screens.dart'; import '../fakes.dart'; @@ -39,12 +40,15 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: CalendarScrollView( - controller: ScrollController(), - calendarState: state, - loadMoreUp: (() {}), - now: now, + home: InheritedConfig( + config: Config.defaultConfig, + child: Scaffold( + body: CalendarScrollView( + controller: ScrollController(), + calendarState: state, + loadMoreUp: (() {}), + now: now, + ), ), ), ), @@ -77,12 +81,15 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: CalendarScrollView( - controller: ScrollController(), - calendarState: state, - loadMoreUp: (() {}), - now: now, + home: InheritedConfig( + config: Config.defaultConfig, + child: Scaffold( + body: CalendarScrollView( + controller: ScrollController(), + calendarState: state, + loadMoreUp: (() {}), + now: now, + ), ), ), ), diff --git a/test/widget/tpay_button_test.dart b/test/widget/tpay_button_test.dart index 46f066b14..088290ef9 100644 --- a/test/widget/tpay_button_test.dart +++ b/test/widget/tpay_button_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:reaxit/api/exceptions.dart'; import 'package:reaxit/blocs.dart'; +import 'package:reaxit/config.dart'; import 'package:reaxit/models.dart'; import 'package:reaxit/ui/widgets.dart'; @@ -30,15 +31,18 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: BlocProvider.value( - value: paymentUserCubit, - child: TPayButton( - amount: '13.37', - successMessage: 'Nice!', - confirmationMessage: 'Are you sure?', - failureMessage: ':(', - onPay: () async => payCompleter.complete(), + home: InheritedConfig( + config: Config.defaultConfig, + child: Scaffold( + body: BlocProvider.value( + value: paymentUserCubit, + child: TPayButton( + amount: '13.37', + successMessage: 'Nice!', + confirmationMessage: 'Are you sure?', + failureMessage: ':(', + onPay: () async => payCompleter.complete(), + ), ), ), ), @@ -80,15 +84,18 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: BlocProvider.value( - value: paymentUserCubit, - child: TPayButton( - amount: '13.37', - successMessage: 'Nice!', - confirmationMessage: 'Are you sure?', - failureMessage: ':(', - onPay: () async => payCompleter.complete(), + home: InheritedConfig( + config: Config.defaultConfig, + child: Scaffold( + body: BlocProvider.value( + value: paymentUserCubit, + child: TPayButton( + amount: '13.37', + successMessage: 'Nice!', + confirmationMessage: 'Are you sure?', + failureMessage: ':(', + onPay: () async => payCompleter.complete(), + ), ), ), ), @@ -127,17 +134,20 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: BlocProvider.value( - value: paymentUserCubit, - child: TPayButton( - amount: '13.37', - successMessage: 'Nice!', - confirmationMessage: 'Are you sure?', - failureMessage: ':(', - onPay: () async { - throw ApiException.unknownError; - }, + home: InheritedConfig( + config: Config.defaultConfig, + child: Scaffold( + body: BlocProvider.value( + value: paymentUserCubit, + child: TPayButton( + amount: '13.37', + successMessage: 'Nice!', + confirmationMessage: 'Are you sure?', + failureMessage: ':(', + onPay: () async { + throw ApiException.unknownError; + }, + ), ), ), ), @@ -167,17 +177,20 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: BlocProvider.value( - value: paymentUserCubit, - child: TPayButton( - amount: '13.37', - successMessage: 'Nice!', - confirmationMessage: 'Are you sure?', - failureMessage: ':(', - onPay: () async { - throw ApiException.unknownError; - }, + home: InheritedConfig( + config: Config.defaultConfig, + child: Scaffold( + body: BlocProvider.value( + value: paymentUserCubit, + child: TPayButton( + amount: '13.37', + successMessage: 'Nice!', + confirmationMessage: 'Are you sure?', + failureMessage: ':(', + onPay: () async { + throw ApiException.unknownError; + }, + ), ), ), ), diff --git a/test/widget/welcome_test.dart b/test/widget/welcome_test.dart index 8581ebdbe..57f752080 100644 --- a/test/widget/welcome_test.dart +++ b/test/widget/welcome_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:reaxit/blocs.dart'; +import 'package:reaxit/config.dart'; import 'package:reaxit/models.dart'; import 'package:reaxit/ui/screens.dart'; @@ -69,11 +70,14 @@ void main() { await tester.pumpWidget( MaterialApp( - home: Scaffold( - body: BlocProvider.value( - value: cubit, - child: WelcomeScreen(), - )), + home: InheritedConfig( + config: Config.defaultConfig, + child: Scaffold( + body: BlocProvider.value( + value: cubit, + child: WelcomeScreen(), + )), + ), ), );