diff --git a/.gitignore b/.gitignore index 4191783..38d1c78 100644 --- a/.gitignore +++ b/.gitignore @@ -42,9 +42,9 @@ target/ */.dart_tool */build -skillmatrix-frontend/build -skillmatrix-frontend/coverage/ -skillmatrix-frontend/pubspec.lock +hartmann-foto-documentation-frontend/build +hartmann-foto-documentation-frontend/coverage/ +hartmann-foto-documentation-frontend/pubspec.lock # Avoid committing generated Javascript files: *.dart.js @@ -60,16 +60,16 @@ skillmatrix-frontend/pubspec.lock -skillmatrix-docker/src/main/docker/skillmatrix-web-*.war -skillmatrix-web/src/main/webapp/.last_build_id -skillmatrix-web/src/main/webapp/assets/ -skillmatrix-web/src/main/webapp/canvaskit/ -skillmatrix-web/src/main/webapp/favicon.png -skillmatrix-web/src/main/webapp/flutter.js -skillmatrix-web/src/main/webapp/flutter_bootstrap.js -skillmatrix-web/src/main/webapp/flutter_service_worker.js -skillmatrix-web/src/main/webapp/icons/ -skillmatrix-web/src/main/webapp/index.html -skillmatrix-web/src/main/webapp/manifest.json -skillmatrix-web/src/main/webapp/version.json +hartmann-foto-documentation-docker/src/main/docker/hartmann-foto-documentation-web-*.war +hartmann-foto-documentation-web/src/main/webapp/.last_build_id +hartmann-foto-documentation-web/src/main/webapp/assets/ +hartmann-foto-documentation-web/src/main/webapp/canvaskit/ +hartmann-foto-documentation-web/src/main/webapp/favicon.png +hartmann-foto-documentation-web/src/main/webapp/flutter.js +hartmann-foto-documentation-web/src/main/webapp/flutter_bootstrap.js +hartmann-foto-documentation-web/src/main/webapp/flutter_service_worker.js +hartmann-foto-documentation-web/src/main/webapp/icons/ +hartmann-foto-documentation-web/src/main/webapp/index.html +hartmann-foto-documentation-web/src/main/webapp/manifest.json +hartmann-foto-documentation-web/src/main/webapp/version.json diff --git a/Jenkinsfile b/Jenkinsfile index b7f963a..66019c1 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -58,7 +58,7 @@ pipeline { ''' } } -/* + stage ('Build Frontend') { steps { echo "running Frontend build for branch ${env.BRANCH_NAME}" @@ -112,7 +112,7 @@ pipeline { } } } -*/ + stage ('Build') { steps { echo "running build for branch ${env.BRANCH_NAME}" diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java index 5b19906..e1140bc 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java @@ -43,6 +43,6 @@ public class CustomerPictureService extends AbstractService { // FIXME: do query List customers = queryService.callNamedQueryList(Customer.FIND_ALL); customers.forEach(c -> c.getPictures().size()); - return customers.parallelStream().map(c -> CustomerListValue.builder(c)).toList(); + return customers.parallelStream().map(CustomerListValue::builder).toList(); } } diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/JwtTokenUtil.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/JwtTokenUtil.java index 9256f87..bbc5c22 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/JwtTokenUtil.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/JwtTokenUtil.java @@ -38,8 +38,8 @@ public class JwtTokenUtil { private static final long REFRESH_TOKEN_VALIDITY = 30 * 24 * 60 * 60 * 1000L; // 30 days private static final long TEMP_2FA_TOKEN_VALIDITY = 5 * 60 * 1000L; // 5 minutes - private static final String ISSUER = "skillmatrix-jwt-issuer"; - private static final String AUDIENCE = "skillmatrix-api"; + private static final String ISSUER = "foto-jwt-issuer"; + private static final String AUDIENCE = "foto-api"; private PrivateKey privateKey; private PublicKey publicKey; diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/LoginUtils.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/LoginUtils.java index bf73385..34efbef 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/LoginUtils.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/utils/LoginUtils.java @@ -43,13 +43,13 @@ public class LoginUtils { private Optional authenticate(String username, String password) { try { - LOG.error("Login with username: " + username + " password: " + password); + LOG.debug("Login with username & password " + username); Principal principal = new NamePrincipal(username); PasswordGuessEvidence evidence = new PasswordGuessEvidence(password.toCharArray()); SecurityDomain sd = SecurityDomain.getCurrent(); SecurityIdentity identity = sd.authenticate(principal, evidence); - LOG.error("Login identity: " + identity); + LOG.debug("Login identity: " + identity); return Optional.ofNullable(identity); } catch (RealmUnavailableException | SecurityException e) { LOG.warn("Failed to authenticate user " + e.getMessage(), e); diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/LoginResource.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/LoginResource.java index e456f28..ac8b009 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/LoginResource.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/LoginResource.java @@ -22,6 +22,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; import marketing.heyday.hartmann.fotodocumentation.core.service.LoginService; import marketing.heyday.hartmann.fotodocumentation.core.utils.LoginUtils; import marketing.heyday.hartmann.fotodocumentation.rest.vo.TokenPairValue; @@ -58,7 +59,7 @@ public class LoginResource { Optional identity = loginUtils.authenticate(httpServletRequest); if (identity.isEmpty()) { LOG.debug("identity empty login invalid"); - return Response.status(401).build(); + return Response.status(Status.UNAUTHORIZED).build(); } String username = identity.get().getPrincipal().getName(); diff --git a/hartmann-foto-documentation-docker/src/main/docker/hartmann-foto-documentation-web-1.0.0-SNAPSHOT.war b/hartmann-foto-documentation-docker/src/main/docker/hartmann-foto-documentation-web-1.0.0-SNAPSHOT.war index 267279b..9f17fc9 100644 Binary files a/hartmann-foto-documentation-docker/src/main/docker/hartmann-foto-documentation-web-1.0.0-SNAPSHOT.war and b/hartmann-foto-documentation-docker/src/main/docker/hartmann-foto-documentation-web-1.0.0-SNAPSHOT.war differ diff --git a/hartmann-foto-documentation-docker/src/main/docker/standalone-fotodocumentation.xml b/hartmann-foto-documentation-docker/src/main/docker/standalone-fotodocumentation.xml index 7baca11..643c220 100644 --- a/hartmann-foto-documentation-docker/src/main/docker/standalone-fotodocumentation.xml +++ b/hartmann-foto-documentation-docker/src/main/docker/standalone-fotodocumentation.xml @@ -333,7 +333,7 @@ eZlo8cWlAC5welD3dz1qxEo= - DiContainer.get(); + JwtTokenStorage get _jwtTokenStorage => DiContainer.get(); + HttpClientUtils get httpClientUtils => DiContainer.get(); + + Future
getAuthHeader() async { + final accessToken = await _jwtTokenStorage.getAccessToken(); + + if (accessToken != null && accessToken.isNotEmpty) { + // Use JWT Bearer token + return Header('Authorization', 'Bearer $accessToken'); + } else { + return const Header("Accept-Language", "en-US"); + } + } + + Exception getServerError(Response response) { + return Exception("Error receiving data from server"); + } + + Future> runGetListWithAuth(String uriStr, List Function(dynamic) convert) async { + http.Client client = httpClientUtils.client; + try { + Header cred = await getAuthHeader(); + Uri uri = Uri.parse(uriStr); + var response = await client.get(uri, headers: {cred.name: cred.value}); + if (response.statusCode == 200) { + String text = response.body; + var jsonArray = jsonDecode(text); + return convert(jsonArray); + } else { + throw ServerError(response.statusCode); // Exception("Failed to get server data ${response.statusCode}"); + } + } catch (e) { + logger.e("exception $e"); + rethrow; + } + } + + Future runGetWithAuth(String uriStr, T Function(dynamic) convert) async { + http.Client client = httpClientUtils.client; + try { + Header cred = await getAuthHeader(); + Uri uri = Uri.parse(uriStr); + var response = await client.get(uri, headers: {cred.name: cred.value}); + if (response.statusCode == 200) { + String text = response.body; + var jsonArray = jsonDecode(text); + return convert(jsonArray); + } else { + throw ServerError(response.statusCode); // Exception("Failed to get server data ${response.statusCode}"); + } + } catch (e) { + logger.e("exception $e"); + rethrow; + } + } + + Future runDeleteWithAuth(String uriStr) async { + http.Client client = httpClientUtils.client; + Header cred = await getAuthHeader(); + Uri uri = Uri.parse(uriStr); + var response = await client.delete(uri, headers: {cred.name: cred.value}); + return response.statusCode == 200; + } + + Future runPutWithAuth(String uriStr) async { + http.Client client = httpClientUtils.client; + Header cred = await getAuthHeader(); + Uri uri = Uri.parse(uriStr); + var response = await client.put(uri, headers: {cred.name: cred.value}); + return response.statusCode == 200; + } + + Future> runSaveNew(String uriStr, T dtoObj, Function(http.Response response, T dto) processReply) async { + http.Client client = httpClientUtils.client; + try { + Header cred = await getAuthHeader(); + String body = jsonEncode(dtoObj.toMap()); + + Uri uri = Uri.parse(uriStr); + var response = await client.post(uri, headers: {cred.name: cred.value, "Accept": "application/json", "Content-Type": "application/json"}, body: body); + return processReply(response, dtoObj); + } catch (e) { + logger.e("exception $e"); + } + return ServerReply(ServerState.error, dtoObj); + } + + Future> runSaveUpdate(String uriStr, T dtoObj, Function(http.Response response, T dto) processReply) async { + http.Client client = httpClientUtils.client; + try { + Header cred = await getAuthHeader(); + String body = jsonEncode(dtoObj.toMap()); + + Uri uri = Uri.parse(uriStr); + var response = await client.put(uri, headers: {cred.name: cred.value, "Accept": "application/json", "Content-Type": "application/json"}, body: body); + + return processReply(response, dtoObj); + } catch (e) { + logger.e("exception $e"); + } + return ServerReply(ServerState.error, dtoObj); + } + + ServerReply processServerResponse(Response response, T dto, T Function(Map json) fromJson) { + if (response.statusCode == 200) { + String text = response.body; + var json = jsonDecode(text); + var dto = fromJson(json); + return ServerReply(ServerState.ok, dto); + } else if (response.statusCode == 400 || response.statusCode == 409) { + String text = response.body; + try { + var json = jsonDecode(text); + var error = ErrorDto.fromJson(json); + + return ServerReply(ServerState.duplicate, dto, error: error); + } catch (e) { + return ServerReply(ServerState.error, dto, error: ErrorDto(response.statusCode, text)); + } + } else if (response.statusCode == 403) { + var error = ErrorDto(403, "Not allowed."); + return ServerReply(ServerState.error, dto, error: error); + } + return ServerReply(ServerState.error, dto, error: ErrorDto(response.statusCode, "Internal server error")); + } +} + +class Header { + final String name; + final String value; + + const Header(this.name, this.value); + + @override + String toString() => '$runtimeType: $name, $value'; +} + +class ServerReply { + ServerState state; + T entity; + ErrorDto? error; + + ServerReply(this.state, this.entity, {this.error}); + + @override + String toString() => '$runtimeType: $state, $entity, $error'; +} + +class ServerError { + int statusCode; + + ServerError(this.statusCode); +} + +enum ServerState { + ok, + duplicate, + error, + ; +} diff --git a/hartmann-foto-documentation-frontend/lib/controller/login_controller.dart b/hartmann-foto-documentation-frontend/lib/controller/login_controller.dart new file mode 100644 index 0000000..d7b1c7b --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/controller/login_controller.dart @@ -0,0 +1,102 @@ +import 'dart:convert' show base64, utf8; +import 'dart:convert' show jsonDecode, jsonEncode; + +import 'package:fotodocumentation/controller/base_controller.dart'; +import 'package:fotodocumentation/dto/jwt_token_pair_dto.dart'; +import 'package:fotodocumentation/main.dart' show logger; +import 'package:fotodocumentation/utils/di_container.dart'; +import 'package:fotodocumentation/utils/jwt_token_storage.dart'; +import 'package:http/http.dart' as http; + +typedef AuthenticateReply = ({JwtTokenPairDto? jwtTokenPairDto}); + +abstract interface class LoginController { + Future authenticate(String username, String password); + Future refreshAccessToken(); + Future isUsingJwtAuth(); +} + +class LoginControllerImpl extends BaseController implements LoginController { + final String path = "login"; + + JwtTokenStorage get _jwtTokenStorage => DiContainer.get(); + + @override + Future authenticate(String username, String password) async { + http.Client client = httpClientUtils.client; + try { + Header cred = _getLoginHeader(username, password); + String uriStr = '${uriUtils.getBaseUrl()}$path'; + Uri uri = Uri.parse(uriStr); + + var response = await client.get(uri, headers: {cred.name: cred.value}); + if (response.statusCode == 200) { + final Map data = Map.castFrom(jsonDecode(response.body)); + + final tokenPair = JwtTokenPairDto.fromJson(data); + + // Store tokens securely + await _jwtTokenStorage.saveTokens(tokenPair.accessToken, tokenPair.refreshToken); + + // Load user data using the new token + return (jwtTokenPairDto: tokenPair); + } else { + logger.e('Authentication failed: ${response.statusCode} ${response.body}'); + return (jwtTokenPairDto: null); + } + } catch (e) { + logger.e("Authentication error: $e"); + return (jwtTokenPairDto: null); + } + } + + @override + Future refreshAccessToken() async { + try { + final refreshToken = await _jwtTokenStorage.getRefreshToken(); + if (refreshToken == null) { + logger.i('No refresh token available'); + return false; + } + String uriStr = '${uriUtils.getBaseUrl()}$path/login/refresh'; + Uri uri = Uri.parse(uriStr); + + final response = await http.post( + uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'refreshToken': refreshToken, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final newAccessToken = data['accessToken'] as String; + + // Update only the access token (keep same refresh token) + await _jwtTokenStorage.updateAccessToken(newAccessToken); + + logger.d('Access token refreshed successfully'); + return true; + } else { + logger.d('Token refresh failed: ${response.statusCode} ${response.body}'); + return false; + } + } catch (e) { + logger.e('Token refresh error: $e'); + return false; + } + } + + @override + Future isUsingJwtAuth() async { + return await _jwtTokenStorage.hasTokens(); + } + + Header _getLoginHeader(String username, String password) { + String combined = "$username:$password"; + final bytes = utf8.encode(combined); + String asBase64 = base64.encode(bytes); + return Header("Authorization", "Basic $asBase64"); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/dto/base_dto.dart b/hartmann-foto-documentation-frontend/lib/dto/base_dto.dart new file mode 100644 index 0000000..2a47fdb --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/dto/base_dto.dart @@ -0,0 +1,15 @@ +final class ErrorDto { + int error; + String message; + + ErrorDto(this.error, this.message); + + ErrorDto.fromJson(Map json) + : error = json['error'] as int, + message = json['message']; +} + +abstract interface class DtoMapAble { + Map toMap(); +} + diff --git a/hartmann-foto-documentation-frontend/lib/dto/jwt_token_pair_dto.dart b/hartmann-foto-documentation-frontend/lib/dto/jwt_token_pair_dto.dart new file mode 100644 index 0000000..80caba9 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/dto/jwt_token_pair_dto.dart @@ -0,0 +1,50 @@ +/// DTO representing a failes login attempt request for 2fa token. +class TokenRequiredDto { + final bool? tokenRequired; + final bool? tokenInValid; + + TokenRequiredDto({ + required this.tokenRequired, + required this.tokenInValid, + }); + + /// Create from JSON response + factory TokenRequiredDto.fromJson(Map json) { + return TokenRequiredDto( + tokenRequired: json['tokenRequired'] as bool, + tokenInValid: json['tokenInValid'] as bool, + ); + } +} + +/// DTO representing a pair of JWT tokens from the backend. +class JwtTokenPairDto { + final String accessToken; + final String refreshToken; + + JwtTokenPairDto({ + required this.accessToken, + required this.refreshToken, + }); + + /// Create from JSON response + factory JwtTokenPairDto.fromJson(Map json) { + return JwtTokenPairDto( + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] as String, + ); + } + + /// Convert to JSON (for serialization if needed) + Map toJson() { + return { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + }; + } + + @override + String toString() { + return 'JwtTokenPairDto{accessToken: [REDACTED], refreshToken: [REDACTED]}'; + } +} diff --git a/hartmann-foto-documentation-frontend/lib/l10n/app_de.arb b/hartmann-foto-documentation-frontend/lib/l10n/app_de.arb new file mode 100644 index 0000000..18f6c9f --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/l10n/app_de.arb @@ -0,0 +1,75 @@ +{ + "@@locale": "de", + "searchTFHint": "Suchtext", + "@searchTFHint": { + "description": "Search hint TextField" + }, + "searchButtonLabel": "Suchen", + "@searchButtonLabel": { + "description": "Search button label" + }, + "loginUsernameTFLabel": "Benutzername", + "@loginUsernameTFLabel": { + "description": "Usernamt TextField Label" + }, + "loginPasswordTFLabel": "Passwort", + "@loginPasswordTFLabel": { + "description": "Password TextField Label" + }, + "loginLoginButtonLabel": "Anmelden", + "@loginLoginButtonLabel": { + "description": "Login Button Label" + }, + "errorWidgetStatusCode": "Statuscode {statusCode}", + "@errorWidgetStatusCode": { + "description": "Error message showing server status code", + "placeholders": { + "statusCode": { + "type": "int" + } + } + }, + + "errorWidget": "Fehler: {name}", + "@errorWidget": { + "description": "Error widget text", + "placeholders": { + "name": { + "type": "String", + "example": "Error text" + } + } + }, + "errorWidgetRetryButton": "Wiederholen", + "@errorWidgetRetryButton": { + "description": "Retry button text for error widget" + }, + "submitWidget": "Speichern", + "@submitWidget": { + "description": "Save Button text" + }, + "textInputWidgetValidatorText": "Bitte geben Sie einen Text ein", + "@textInputWidgetValidatorText": { + "description": "Awaiting result info text" + }, + "waitingWidget": "Warten auf Ergebnis …", + "@waitingWidget": { + "description": "Awaiting result info text" + }, + "deleteDialogTitle": "Löschen", + "@deleteDialogTitle": { + "description": "Delete dialog title" + }, + "deleteDialogText": "Sind Sie sicher, dass Sie diese Eintrag löschen möchten?", + "@deleteDialogText": { + "description": "Delete dialog text" + }, + "deleteDialogButtonCancel": "Nein", + "@deleteDialogButtonCancel": { + "description": "Cancel Button text" + }, + "deleteDialogButtonApprove": "Ja", + "@deleteDialogButtonApprove": { + "description": "Approve Button text" + } +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/l10n/app_localizations.dart b/hartmann-foto-documentation-frontend/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..af6b044 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/l10n/app_localizations.dart @@ -0,0 +1,217 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_de.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [Locale('de')]; + + /// Search hint TextField + /// + /// In de, this message translates to: + /// **'Suchtext'** + String get searchTFHint; + + /// Search button label + /// + /// In de, this message translates to: + /// **'Suchen'** + String get searchButtonLabel; + + /// Usernamt TextField Label + /// + /// In de, this message translates to: + /// **'Benutzername'** + String get loginUsernameTFLabel; + + /// Password TextField Label + /// + /// In de, this message translates to: + /// **'Passwort'** + String get loginPasswordTFLabel; + + /// Login Button Label + /// + /// In de, this message translates to: + /// **'Anmelden'** + String get loginLoginButtonLabel; + + /// Error message showing server status code + /// + /// In de, this message translates to: + /// **'Statuscode {statusCode}'** + String errorWidgetStatusCode(int statusCode); + + /// Error widget text + /// + /// In de, this message translates to: + /// **'Fehler: {name}'** + String errorWidget(String name); + + /// Retry button text for error widget + /// + /// In de, this message translates to: + /// **'Wiederholen'** + String get errorWidgetRetryButton; + + /// Save Button text + /// + /// In de, this message translates to: + /// **'Speichern'** + String get submitWidget; + + /// Awaiting result info text + /// + /// In de, this message translates to: + /// **'Bitte geben Sie einen Text ein'** + String get textInputWidgetValidatorText; + + /// Awaiting result info text + /// + /// In de, this message translates to: + /// **'Warten auf Ergebnis …'** + String get waitingWidget; + + /// Delete dialog title + /// + /// In de, this message translates to: + /// **'Löschen'** + String get deleteDialogTitle; + + /// Delete dialog text + /// + /// In de, this message translates to: + /// **'Sind Sie sicher, dass Sie diese Eintrag löschen möchten?'** + String get deleteDialogText; + + /// Cancel Button text + /// + /// In de, this message translates to: + /// **'Nein'** + String get deleteDialogButtonCancel; + + /// Approve Button text + /// + /// In de, this message translates to: + /// **'Ja'** + String get deleteDialogButtonApprove; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['de'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'de': + return AppLocalizationsDe(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/hartmann-foto-documentation-frontend/lib/l10n/app_localizations_de.dart b/hartmann-foto-documentation-frontend/lib/l10n/app_localizations_de.dart new file mode 100644 index 0000000..3851fc8 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/l10n/app_localizations_de.dart @@ -0,0 +1,60 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class AppLocalizationsDe extends AppLocalizations { + AppLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get searchTFHint => 'Suchtext'; + + @override + String get searchButtonLabel => 'Suchen'; + + @override + String get loginUsernameTFLabel => 'Benutzername'; + + @override + String get loginPasswordTFLabel => 'Passwort'; + + @override + String get loginLoginButtonLabel => 'Anmelden'; + + @override + String errorWidgetStatusCode(int statusCode) { + return 'Statuscode $statusCode'; + } + + @override + String errorWidget(String name) { + return 'Fehler: $name'; + } + + @override + String get errorWidgetRetryButton => 'Wiederholen'; + + @override + String get submitWidget => 'Speichern'; + + @override + String get textInputWidgetValidatorText => 'Bitte geben Sie einen Text ein'; + + @override + String get waitingWidget => 'Warten auf Ergebnis …'; + + @override + String get deleteDialogTitle => 'Löschen'; + + @override + String get deleteDialogText => + 'Sind Sie sicher, dass Sie diese Eintrag löschen möchten?'; + + @override + String get deleteDialogButtonCancel => 'Nein'; + + @override + String get deleteDialogButtonApprove => 'Ja'; +} diff --git a/hartmann-foto-documentation-frontend/lib/main.dart b/hartmann-foto-documentation-frontend/lib/main.dart new file mode 100644 index 0000000..42f8d02 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/main.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:logger/web.dart' show DateTimeFormat, Logger, PrettyPrinter; +import 'package:fotodocumentation/controller/login_controller.dart'; +import 'package:fotodocumentation/l10n/app_localizations.dart'; +import 'package:fotodocumentation/utils/di_container.dart'; +import 'package:fotodocumentation/utils/main_utils.dart'; +import 'package:fotodocumentation/utils/global_router.dart'; + +var logger = Logger( + printer: PrettyPrinter(methodCount: 2, errorMethodCount: 8, colors: true, printEmojis: true, dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart), +); + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + DiContainer.instance.initState(); + + final theme = await ThemeLoader.loadTheme(); + + LoginController loginController = DiContainer.get(); + //await loginController.isLoggedIn(); + runApp(FotoDocumentationApp(theme: theme)); +} + +class FotoDocumentationApp extends StatelessWidget { + final ThemeData theme; + + const FotoDocumentationApp({super.key, required this.theme}); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'Hartmann Foto App', + localizationsDelegates: [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: [ + Locale('de'), + ], + scrollBehavior: MyCustomScrollBehavior(), // <== needed for web horizontal scroll behavior + theme: theme, + routerConfig: GlobalRouter.router); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart new file mode 100644 index 0000000..72f592d --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class CustomerWidget extends StatefulWidget { + const CustomerWidget({super.key}); + + @override + State createState() => _CustomerWidgetState(); +} + +class _CustomerWidgetState extends State { + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/pages/landing_page_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/landing_page_widget.dart new file mode 100644 index 0000000..a636bce --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/landing_page_widget.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class LandingPageWidget extends StatefulWidget { + final Widget child; + const LandingPageWidget({super.key, required this.child}); + + @override + State createState() => _LandingPageWidgetState(); +} + +class _LandingPageWidgetState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: widget.child, + ); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/login/login_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/login/login_widget.dart new file mode 100644 index 0000000..3bd35a3 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/login/login_widget.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:fotodocumentation/controller/login_controller.dart'; +import 'package:fotodocumentation/dto/jwt_token_pair_dto.dart'; +import 'package:fotodocumentation/l10n/app_localizations.dart'; +import 'package:fotodocumentation/pages/ui_utils/component/general_submit_widget.dart'; +import 'package:fotodocumentation/pages/ui_utils/header_utils.dart'; +import 'package:fotodocumentation/pages/ui_utils/modern_app_bar.dart'; +import 'package:fotodocumentation/utils/di_container.dart'; +import 'package:fotodocumentation/utils/login_credentials.dart'; + +class LoginWidget extends StatefulWidget { + const LoginWidget({super.key}); + + @override + State createState() => _LoginWidgetState(); +} + +class _LoginWidgetState extends State { + HeaderUtils get _headerUtils => DiContainer.get(); + LoginController get _loginController => DiContainer.get(); + LoginCredentials get _loginCredentials => DiContainer.get(); + + final GlobalKey _formKey = GlobalKey(); + + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + + String? _error; + final FocusNode _focusNode = FocusNode(); + + @override + void dispose() { + _focusNode.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: ModernAppBar( + title: _headerUtils.titleWidget("Login title"), + actions: [], + ), + body: _body(context), + ); + } + + Widget _body(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.grey[50]!, + Colors.white, + ], + ), + ), + child: _content(context), + ); + } + + Widget _content(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(20.0), + child: Form( + key: _formKey, + child: KeyboardListener( + focusNode: _focusNode, + onKeyEvent: (event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) { + _actionSubmit(context); + } + }, + child: ListView( + children: [ + Card( + elevation: 4, + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(30.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + key: Key("username"), + controller: _usernameController, + decoration: InputDecoration( + border: UnderlineInputBorder(), + labelText: AppLocalizations.of(context)!.loginUsernameTFLabel, + ), + ), + const SizedBox(height: 10), + TextFormField( + key: Key("password"), + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + border: UnderlineInputBorder(), + labelText: AppLocalizations.of(context)!.loginPasswordTFLabel, + ), + ), + const SizedBox(height: 10), + if (_error != null) ...[ + Text( + _error!, + style: const TextStyle(color: Colors.red), + ), + const SizedBox(height: 10), + ], + GeneralSubmitWidget( + key: const Key("submit"), + onSelect: () async => await _actionSubmit(context), + title: AppLocalizations.of(context)!.loginLoginButtonLabel, + ), + const SizedBox(height: 30), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Future _actionSubmit(BuildContext context) async { + String username = _usernameController.text; + String password = _passwordController.text; + + AuthenticateReply authenticateReply = await _loginController.authenticate(username, password); + + JwtTokenPairDto? jwtTokenPairDto = authenticateReply.jwtTokenPairDto; + if (jwtTokenPairDto == null) { + setState(() => _error = "Error message"); + return; + } + + _loginCredentials.setLoggedIn(true); + if (context.mounted) { + context.go("/"); + } + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/general_error_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/general_error_widget.dart new file mode 100644 index 0000000..3dfbc98 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/general_error_widget.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/l10n/app_localizations.dart'; +import 'package:fotodocumentation/controller/base_controller.dart'; + +class GeneralErrorWidget extends StatelessWidget { + final String error; + final Function()? reload; + final int? statusCode; + + const GeneralErrorWidget({super.key, required this.error, this.reload, this.statusCode}); + + factory GeneralErrorWidget.fromServerError(ServerError serverError, {Function()? reload}) { + return GeneralErrorWidget(error: "", reload: reload, statusCode: serverError.statusCode); + } + + factory GeneralErrorWidget.fromSnapshot(AsyncSnapshot snapshot, {Function()? reload}) { + var error = snapshot.error; + if (error is ServerError) { + return GeneralErrorWidget.fromServerError(error, reload: () => reload); + } + return GeneralErrorWidget(error: snapshot.error.toString(), reload: () => reload); + } + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + final String errorMessage = statusCode != null + ? localizations.errorWidgetStatusCode(statusCode!) + : localizations.errorWidget(error); + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 60, color: Colors.red[300]), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + errorMessage, + style: TextStyle(color: Colors.red[700]), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + if (reload != null) ...[ + ElevatedButton( + onPressed: reload, + child: Text(localizations.errorWidgetRetryButton), + ), + ], + ], + ), + ); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/general_submit_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/general_submit_widget.dart new file mode 100644 index 0000000..5d3b993 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/general_submit_widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/l10n/app_localizations.dart'; +import 'package:fotodocumentation/pages/ui_utils/general_style.dart'; +import 'package:fotodocumentation/utils/di_container.dart'; + +typedef SubmitCallback = void Function(); + +class GeneralSubmitWidget extends StatelessWidget { + GeneralStyle get _generalStyle => DiContainer.get(); + + final SubmitCallback onSelect; + final String? title; + + const GeneralSubmitWidget({super.key, required this.onSelect, this.title}); + + @override + Widget build(BuildContext context) { + String text = title ?? AppLocalizations.of(context)!.submitWidget; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ElevatedButton( + key: Key("SubmitWidgetButton"), + style: _generalStyle.elevatedButtonStyle, + onPressed: onSelect, + child: Text(text), + ), + ], + ); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/page_header_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/page_header_widget.dart new file mode 100644 index 0000000..2203e39 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/page_header_widget.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +class PageHeaderWidget extends StatelessWidget { + final IconData iconData; + final String text; + final String subText; + final Color? iconColor; + + const PageHeaderWidget({super.key, this.iconData = Icons.business, required this.text, this.subText = "", this.iconColor}); + + @override + Widget build(BuildContext context) { + final color = iconColor ?? Theme.of(context).colorScheme.primary; + return Card( + elevation: 2, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Colors.grey[300]!, + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withAlpha(51), + shape: BoxShape.circle, + ), + child: Icon( + iconData, + size: 32, + color: color, + ), + ), + const SizedBox(width: 16), + Text( + text, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + if (subText.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + subText, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/search_bar_card_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/search_bar_card_widget.dart new file mode 100644 index 0000000..2f4c052 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/search_bar_card_widget.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/l10n/app_localizations.dart'; + +class SearchBarCardWidget extends StatefulWidget { + final TextEditingController searchController; + final Function(String) onSearch; + const SearchBarCardWidget({super.key, required this.searchController, required this.onSearch}); + + @override + State createState() => _SearchBarCardWidgetState(); +} + +class _SearchBarCardWidgetState extends State { + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Colors.grey[300]!, + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: TextField( + key: Key("Search_text_field"), + controller: widget.searchController, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.searchTFHint, + border: InputBorder.none, + prefixIcon: const Icon(Icons.search, size: 28), + contentPadding: EdgeInsets.zero, + isDense: true, + suffixIcon: InkWell( + key: Key("Search_text_clear_button"), + onTap: () => _actionClear(), + child: const Icon( + Icons.close, + color: Colors.black, + ), + ) + ), + onSubmitted: (_) => _actionSubmit(), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + key: Key("Search_text_button"), + onPressed: _actionSubmit, + icon: const Icon(Icons.search, size: 18), + label: Text(AppLocalizations.of(context)!.searchButtonLabel), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ), + ); + } + + void _actionSubmit() { + widget.onSearch(widget.searchController.text); + } + + void _actionClear() { + widget.searchController.text = ""; + widget.onSearch(widget.searchController.text); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/text_input_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/text_input_widget.dart new file mode 100644 index 0000000..e09551f --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/text_input_widget.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/l10n/app_localizations.dart'; + +import 'package:provider/provider.dart'; + +class TextInputWidget extends StatelessWidget { + final String labelText; + final bool required; + final bool obscureText; + final bool readOnly; + final Function? onTap; + const TextInputWidget({super.key, required this.labelText, this.required = false, this.obscureText = false, this.readOnly = false, this.onTap}); + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, controller, child) { + return TextFormField( + readOnly: readOnly, + obscureText: obscureText, + controller: controller, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: labelText, + ), + validator: (String? value) => required && (value == null || value.isEmpty) ? AppLocalizations.of(context)!.textInputWidgetValidatorText : null, + onTap: () => onTap?.call(), + ); + }); + } +} + +class TextMultiInputWidget extends StatelessWidget { + final String labelText; + final bool required; + final bool obscureText; + final bool readOnly; + final Function? onTap; + final int maxLines; + const TextMultiInputWidget({super.key, required this.labelText, this.required = false, this.obscureText = false, this.readOnly = false, this.maxLines = 6, this.onTap}); + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, controller, child) { + return TextFormField( + readOnly: readOnly, + minLines: 3, // Set this + maxLines: maxLines, // and this + keyboardType: TextInputType.multiline, + obscureText: obscureText, + controller: controller, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: labelText, + ), + validator: (String? value) => required && (value == null || value.isEmpty) ? AppLocalizations.of(context)!.textInputWidgetValidatorText : null, + onTap: () => onTap?.call(), + ); + }); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/waiting_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/waiting_widget.dart new file mode 100644 index 0000000..3d6a3d6 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/waiting_widget.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/l10n/app_localizations.dart'; + +class WaitingWidget extends StatelessWidget { + const WaitingWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 60, + height: 60, + child: CircularProgressIndicator(), + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text(AppLocalizations.of(context)!.waitingWidget), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/delete_dialog.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/delete_dialog.dart new file mode 100644 index 0000000..471df04 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/delete_dialog.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; + +import 'package:fotodocumentation/main.dart' show logger; +import 'package:fotodocumentation/l10n/app_localizations.dart'; +import 'package:fotodocumentation/pages/ui_utils/dialog/snackbar_utils.dart'; +import 'package:fotodocumentation/utils/di_container.dart'; + +class DeleteDialog extends StatelessWidget { + static SnackbarUtils get _snackbarUtils => DiContainer.get(); + + const DeleteDialog({super.key}); + + static Future show(BuildContext context, Future Function() doDelete) async { + await _openDialog(context).then((value) async { + if (value != null && value && context.mounted) { + logger.d("Delete popup result $value"); + var result = await doDelete(); + if (context.mounted && result.msg.isNotEmpty) { + _snackbarUtils.showSnackbar(context, result.msg, result.warning); + } + } + }); + } + + static Future _openDialog(BuildContext context) async { + return showDialog( + context: context, + barrierDismissible: false, // user must tap button! + builder: (BuildContext context) { + return const DeleteDialog( + key: Key("delete_dialog"), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + + return AlertDialog( + backgroundColor: Colors.white, + scrollable: false, + titlePadding: const EdgeInsets.all(16.0), + contentPadding: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 16.0), + title: _titleWidget(context, loc), + content: _content(context, loc), + ); + } + + Widget _titleWidget(BuildContext context, AppLocalizations loc) { + return Card( + elevation: 4, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Colors.grey[300]!, + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + size: 32, + color: Colors.orange[700], + ), + const SizedBox(width: 16), + Text( + loc.deleteDialogTitle, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + IconButton( + key: const Key("close_button"), + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context, false), + ), + ], + ), + ), + ); + } + + Widget _content(BuildContext context, AppLocalizations loc) { + return SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Card( + elevation: 2, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Colors.grey[300]!, + width: 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + size: 48, + color: Colors.orange[700], + ), + const SizedBox(width: 16), + Expanded( + child: Text( + loc.deleteDialogText, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + key: const Key("delete_dialog:cancel"), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => Navigator.pop(context, false), + child: Text(loc.deleteDialogButtonCancel), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + key: const Key("delete_dialog:approve"), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + backgroundColor: Colors.red[600], + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.delete, size: 20), + label: Text(loc.deleteDialogButtonApprove), + ), + ], + ), + ], + ), + ); + } +} + +class DeleteDialogResult { + final String msg; + final bool warning; + + DeleteDialogResult({required this.msg, required this.warning}); +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/dialog_result.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/dialog_result.dart new file mode 100644 index 0000000..682a170 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/dialog_result.dart @@ -0,0 +1,11 @@ +class DialogResult { + final DialogResultType type; + final T? dto; + + const DialogResult({required this.type, this.dto}); +} + +enum DialogResultType { + create, + add; +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/snackbar_utils.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/snackbar_utils.dart new file mode 100644 index 0000000..7712646 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/snackbar_utils.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +abstract interface class SnackbarUtils { + void showSnackbar(BuildContext context, String msg, bool warning); + void showSnackbarPopup(BuildContext context, String msg, bool warning); +} + +class SnackbarUtilsImpl implements SnackbarUtils { + @override + void showSnackbar(BuildContext context, String msg, bool warning) { + var snackBar = SnackBar( + content: _contentFor(context, msg, warning), + backgroundColor: Colors.white, + behavior: SnackBarBehavior.floating, + showCloseIcon: true, + closeIconColor: Theme.of(context).colorScheme.inversePrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + side: BorderSide(width: 2.0, style: BorderStyle.solid, color: Theme.of(context).colorScheme.inversePrimary), + ), + margin: EdgeInsets.only(bottom: MediaQuery.of(context).size.height - 130, left: MediaQuery.of(context).size.width - 400, right: 10), + ); + + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + @override + void showSnackbarPopup(BuildContext context, String msg, bool warning) { + var snackBar = SnackBar( + content: _contentFor(context, msg, warning), + backgroundColor: Colors.white, + behavior: SnackBarBehavior.floating, + showCloseIcon: true, + closeIconColor: Theme.of(context).colorScheme.inversePrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + side: BorderSide(width: 2.0, style: BorderStyle.solid, color: Theme.of(context).colorScheme.inversePrimary), + ), + width: 350, + //margin: EdgeInsets.only(bottom: MediaQuery.of(context).size.height - 100, left: MediaQuery.of(context).size.width - 350, right: 10), + ); + + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + Widget _contentFor(BuildContext context, String msg, bool warning) { + var icon = _iconFor(context, warning); + + var style = _textStyleFor(context, warning); + return Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + icon, + Text( + msg, + style: style, + ), + ], + ); + } + + Icon _iconFor(BuildContext context, bool warning) { + var color = _contentColor(context, warning); + return warning ? Icon(Icons.error, color: color) : Icon(Icons.check_circle_outline, color: color); + } + + TextStyle _textStyleFor(BuildContext context, bool warning) { + var color = _contentColor(context, warning); + var bodyLarge = Theme.of(context).primaryTextTheme.bodyLarge!; + var style = TextStyle( + color: color, + decoration: bodyLarge.decoration, + fontFamily: bodyLarge.fontFamily, + fontSize: bodyLarge.fontSize, + fontWeight: bodyLarge.fontWeight, + letterSpacing: bodyLarge.letterSpacing, + textBaseline: bodyLarge.textBaseline); + return style; + } + + Color _contentColor(BuildContext context, bool warning) { + return warning ? Theme.of(context).colorScheme.error : Theme.of(context).colorScheme.inversePrimary; + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/general_style.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/general_style.dart new file mode 100644 index 0000000..07bb923 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/general_style.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:pinput/pinput.dart'; + +abstract interface class GeneralStyle { + PinTheme get pinTheme; + + ButtonStyle get elevatedButtonStyle; + ButtonStyle get roundedButtonStyle; +} + +class GeneralStyleImpl implements GeneralStyle { + static final ButtonStyle _elevatedButtonStyle = ElevatedButton.styleFrom(textStyle: const TextStyle(fontSize: 20)); + static final ButtonStyle _roundedButtonStyle = ElevatedButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(8)); + + @override + PinTheme get pinTheme => _getPinTheme(); + + @override + ButtonStyle get elevatedButtonStyle => _elevatedButtonStyle; + + @override + ButtonStyle get roundedButtonStyle => _roundedButtonStyle; + + PinTheme _getPinTheme() { + return PinTheme( + width: 56, + height: 56, + textStyle: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + ); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/header_button_wrapper.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/header_button_wrapper.dart new file mode 100644 index 0000000..7aedc2c --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/header_button_wrapper.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +class HeaderButtonWrapper extends StatefulWidget { + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + final Color? iconColor; + final int? badgeCount; + + const HeaderButtonWrapper({ + super.key, + required this.icon, + required this.tooltip, + required this.onPressed, + this.iconColor, + this.badgeCount, + }); + + @override + State createState() => _HeaderButtonWrapperState(); +} + +class _HeaderButtonWrapperState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: Tooltip( + message: widget.tooltip, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + decoration: BoxDecoration( + color: _isHovered ? Colors.blue[50] : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _isHovered ? Colors.blue[200]! : Colors.transparent, + width: 1, + ), + ), + child: IconButton( + icon: Icon( + widget.icon, + size: 24, + color: widget.iconColor ?? Colors.grey[700], + ), + onPressed: widget.onPressed, + splashRadius: 24, + ), + ), + if (widget.badgeCount != null && widget.badgeCount! > 0) + Positioned( + right: 4, + top: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.red[600], + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2, + ), + ), + constraints: const BoxConstraints( + minWidth: 20, + minHeight: 20, + ), + child: Text( + widget.badgeCount! > 99 ? '99+' : widget.badgeCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/header_utils.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/header_utils.dart new file mode 100644 index 0000000..3fc1fe8 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/header_utils.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/utils/di_container.dart'; +import 'package:fotodocumentation/utils/login_credentials.dart'; + +abstract interface class HeaderUtils { + Widget titleWidget(String text); +} + +class HeaderUtilsImpl extends HeaderUtils { + LoginCredentials get _loginCredentials => DiContainer.get(); + + @override + Widget titleWidget(String text) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.school, + size: 28, + color: Colors.blue[700], + ), + const SizedBox(width: 12), + Text( + text, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 4), + if (_loginCredentials.fullname.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(left: 40.0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.blue[200]!, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.person_outline, + size: 14, + color: Colors.blue[700], + ), + const SizedBox(width: 6), + Text( + _loginCredentials.fullname, + style: TextStyle( + fontSize: 12, + color: Colors.blue[900], + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ], + ); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/modern_app_bar.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/modern_app_bar.dart new file mode 100644 index 0000000..db7b262 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/modern_app_bar.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/pages/ui_utils/header_button_wrapper.dart'; + +class ModernAppBar extends StatelessWidget implements PreferredSizeWidget { + final Widget title; + final List actions; + final Widget? leading; + final bool automaticallyImplyLeading; + + const ModernAppBar({ + super.key, + required this.title, + this.actions = const [], + this.leading, + this.automaticallyImplyLeading = true, + }); + + @override + Widget build(BuildContext context) { + Widget? effectiveLeading = _effectiveLeading(context); + return Container( + decoration: BoxDecoration( + color: Colors.grey[50], + border: Border( + bottom: BorderSide( + color: Colors.grey[300]!, + width: 1, + ), + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + children: [ + if (effectiveLeading != null) ...[ + effectiveLeading, + const SizedBox(width: 16), + ], + Expanded(child: title), + const SizedBox(width: 16), + ...actions.map((action) => Padding( + padding: const EdgeInsets.only(left: 8.0), + child: action, + )), + ], + ), + ), + ), + ); + } + + Widget? _effectiveLeading(BuildContext context) { + // Determine if we should show a back button + final ScaffoldState? scaffold = Scaffold.maybeOf(context); + final ModalRoute? parentRoute = ModalRoute.of(context); + final bool hasDrawer = scaffold?.hasDrawer ?? false; + final bool canPop = parentRoute?.canPop ?? false; + final bool useCloseButton = parentRoute is PageRoute && parentRoute.fullscreenDialog; + + Widget? effectiveLeading = leading; + if (effectiveLeading == null && automaticallyImplyLeading) { + if (hasDrawer) { + effectiveLeading = HeaderButtonWrapper( + icon: Icons.menu, + onPressed: () { + Scaffold.of(context).openDrawer(); + }, + tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, + iconColor: Colors.grey[700], + ); + } else if (canPop) { + if (useCloseButton) { + effectiveLeading = HeaderButtonWrapper( + icon: Icons.close, + onPressed: () { + Navigator.of(context).pop(); + }, + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + iconColor: Colors.grey[700], + ); + } else { + effectiveLeading = HeaderButtonWrapper( + icon: Icons.arrow_back, + onPressed: () { + Navigator.of(context).pop(); + }, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + iconColor: Colors.grey[700], + ); + } + } + } + return effectiveLeading; + } + + @override + Size get preferredSize => const Size.fromHeight(80); +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/date_time_utils.dart b/hartmann-foto-documentation-frontend/lib/utils/date_time_utils.dart new file mode 100644 index 0000000..9471d69 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/date_time_utils.dart @@ -0,0 +1,17 @@ +final class DateTimeUtils { + static DateTime? toDateTime(dynamic element) { + if (element == null) { + return null; + } + String text = element.toString(); + int? time = int.tryParse(text); + if (time == null) { + return null; + } + return DateTime.fromMillisecondsSinceEpoch(time); + } + + static int? fromDateTime(DateTime? dt) { + return dt?.millisecondsSinceEpoch; + } +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/utils/di_container.dart b/hartmann-foto-documentation-frontend/lib/utils/di_container.dart new file mode 100644 index 0000000..8a43e06 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/di_container.dart @@ -0,0 +1,38 @@ +import 'package:fotodocumentation/controller/login_controller.dart'; +import 'package:fotodocumentation/pages/ui_utils/dialog/snackbar_utils.dart'; +import 'package:fotodocumentation/pages/ui_utils/general_style.dart'; +import 'package:fotodocumentation/pages/ui_utils/header_utils.dart'; +import 'package:fotodocumentation/utils/http_client_utils.dart'; +import 'package:fotodocumentation/utils/jwt_token_storage.dart'; +import 'package:fotodocumentation/utils/login_credentials.dart'; +import 'package:fotodocumentation/utils/url_utils.dart'; + +class DiContainer { + static final DiContainer instance = DiContainer._privateConstructor(); + DiContainer._privateConstructor(); + + final _container = {}; + + static T get() { + return instance._container[T] as T; + } + + void initState() { + DiContainer.instance.put(LoginCredentials, LoginCredentialsImpl()); + DiContainer.instance.put(GeneralStyle, GeneralStyleImpl()); + DiContainer.instance.put(JwtTokenStorage, JwtTokenStorageImpl()); + DiContainer.instance.put(HttpClientUtils, HttpCLientUtilsImpl()); + DiContainer.instance.put(HeaderUtils, HeaderUtilsImpl()); + DiContainer.instance.put(UrlUtils, UrlUtilsImpl()); + DiContainer.instance.put(SnackbarUtils, SnackbarUtilsImpl()); + DiContainer.instance.put(LoginController, LoginControllerImpl()); + } + + void put(Type key, T object) { + _container[key] = object; + } + + T get2() { + return _container[T] as T; + } +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/extensions.dart b/hartmann-foto-documentation-frontend/lib/utils/extensions.dart new file mode 100644 index 0000000..9a99099 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/extensions.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart' show Colors, Color; + +extension HexColor on Color { + /// String is in the format "aabbcc" or "ffaabbcc" with an optional leading "#". + static Color fromHex(String hexString) { + final buffer = StringBuffer(); + if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); + buffer.write(hexString.replaceFirst('#', '')); + return Color(int.parse(buffer.toString(), radix: 16)); + } +} + +extension RiskColor on Color { + static const Color noRisk = Colors.transparent; + static final Color lowRisk = HexColor.fromHex("#FFFF00"); + static final Color mediumRisk = HexColor.fromHex("#FF9000"); + static final Color highRisk = HexColor.fromHex("#FF4000"); + + static Color colorForRisk(int value) { + if (value == 1) { + return lowRisk; + } else if (value == 2) { + return mediumRisk; + } else if (value == 3) { + return highRisk; + } + return noRisk; + } +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/global_router.dart b/hartmann-foto-documentation-frontend/lib/utils/global_router.dart new file mode 100644 index 0000000..f92dc22 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/global_router.dart @@ -0,0 +1,51 @@ +// needed for web horizontal scroll behavior +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/main.dart'; +import 'package:fotodocumentation/pages/customer/customer_widget.dart'; +import 'package:fotodocumentation/pages/login/login_widget.dart'; +import 'package:fotodocumentation/utils/di_container.dart'; +import 'package:fotodocumentation/utils/login_credentials.dart'; +import 'package:go_router/go_router.dart'; + +class GlobalRouter { + static final GlobalKey rootNavigatorKey = GlobalKey(debugLabel: 'root'); + static final GlobalKey bottomBarNavigatorKey = GlobalKey(debugLabel: 'bottombar'); + static final GlobalKey adminNavigatorKey = GlobalKey(debugLabel: 'admin'); + static final GlobalKey skillEditorNavigatorKey = GlobalKey(debugLabel: 'skillEditor'); + + static final String pathHome = "/home"; + static final String pathLogin = "/login"; + + static final GoRouter router = createRouter(pathHome); + + static GoRouter createRouter(String initialLocation) { + return GoRouter( + navigatorKey: rootNavigatorKey, + initialLocation: initialLocation, + routes: [ + GoRoute( + path: "/", + redirect: (_, __) => pathHome, + ), + GoRoute( + path: pathLogin, + builder: (BuildContext context, GoRouterState state) => const LoginWidget(), + ), + GoRoute( + path: pathHome, + builder: (context, state) => CustomerWidget(), + ), + ], + redirect: (context, state) { + var uriStr = state.uri.toString(); + logger.t("uri $uriStr"); + LoginCredentials loginCredentials = DiContainer.get(); + + if (!loginCredentials.isLoggedIn) { + return pathLogin; + } + return null; + }, + ); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/global_stack.dart b/hartmann-foto-documentation-frontend/lib/utils/global_stack.dart new file mode 100644 index 0000000..a206d9e --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/global_stack.dart @@ -0,0 +1,16 @@ +class GlobalStack { + final _list = []; + + void push(T value) => _list.add(value); + + T pop() => _list.removeLast(); + + T peek() => _list.last; + + bool get isEmpty => _list.isEmpty; + + bool get isNotEmpty => _list.isNotEmpty; + + @override + String toString() => _list.toString(); +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/http_client_factory_app.dart b/hartmann-foto-documentation-frontend/lib/utils/http_client_factory_app.dart new file mode 100644 index 0000000..30f4839 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/http_client_factory_app.dart @@ -0,0 +1,11 @@ +import 'package:http/http.dart' as http; +import 'package:fotodocumentation/utils/http_client_factory_stub.dart'; + +HttpCLientFactory getHttpClientFactory() => HttpClientFactoryApp(); + +class HttpClientFactoryApp extends HttpCLientFactory { + @override + http.Client createHttpClient() { + return http.Client(); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/http_client_factory_stub.dart b/hartmann-foto-documentation-frontend/lib/utils/http_client_factory_stub.dart new file mode 100644 index 0000000..4ff8311 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/http_client_factory_stub.dart @@ -0,0 +1,21 @@ +import 'package:http/http.dart' as http; + +HttpCLientFactory getHttpClientFactory() => throw UnsupportedError('Cannot create http client'); + + +class HttpCLientFactory { + http.Client createHttpClient() { + // Check if running on the Web + /*if (kIsWeb) { + var client = http.Client(); + (client as BrowserClient).withCredentials = true; + return client; + } else if (universal_io.Platform.isAndroid || universal_io.Platform.isIOS) { + // Platform-specific logic for Android and iOS + return http.Client(); + } else { + throw UnsupportedError('Unsupported platform'); + }*/ + throw UnsupportedError('Cannot create http client'); + } +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/utils/http_client_factory_web.dart b/hartmann-foto-documentation-frontend/lib/utils/http_client_factory_web.dart new file mode 100644 index 0000000..62b5883 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/http_client_factory_web.dart @@ -0,0 +1,14 @@ +import 'package:http/browser_client.dart'; +import 'package:http/http.dart' as http; +import 'package:fotodocumentation/utils/http_client_factory_stub.dart'; + +HttpCLientFactory getHttpClientFactory() => HttpClientFactoryWeb(); + +class HttpClientFactoryWeb extends HttpCLientFactory{ + @override + http.Client createHttpClient() { + var client = http.Client(); + (client as BrowserClient).withCredentials = true; + return client; + } +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/utils/http_client_interceptor.dart b/hartmann-foto-documentation-frontend/lib/utils/http_client_interceptor.dart new file mode 100644 index 0000000..cec5ca8 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/http_client_interceptor.dart @@ -0,0 +1,37 @@ +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:fotodocumentation/utils/di_container.dart'; +import 'package:fotodocumentation/utils/login_credentials.dart'; +import 'package:fotodocumentation/utils/global_router.dart'; + +/// HTTP client that intercepts all responses and handles 401 status codes +/// by logging out the user and redirecting to the login page. +class HttpClientInterceptor extends http.BaseClient { + final http.Client _inner; + + HttpClientInterceptor(this._inner); + + @override + Future send(http.BaseRequest request) async { + final response = await _inner.send(request); + + // Check for 401 Unauthorized + if (response.statusCode == 401) { + _handle401Unauthorized(); + } + + return response; + } + + void _handle401Unauthorized() { + // Clear login credentials + final loginCredentials = DiContainer.get(); + loginCredentials.logout(); + + // Navigate to login page using GoRouter + final context = GlobalRouter.rootNavigatorKey.currentContext; + if (context != null) { + context.go(GlobalRouter.pathLogin); + } + } +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/utils/http_client_utils.dart b/hartmann-foto-documentation-frontend/lib/utils/http_client_utils.dart new file mode 100644 index 0000000..cb44af9 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/http_client_utils.dart @@ -0,0 +1,20 @@ +import 'package:http/http.dart' as http; + +import 'http_client_factory_stub.dart' if (dart.library.io) 'http_client_factory_app.dart' if (dart.library.js) 'http_client_factory_web.dart'; +import 'http_client_interceptor.dart'; + +abstract class HttpClientUtils { + http.Client get client; +} + +class HttpCLientUtilsImpl extends HttpClientUtils { + http.Client? _client; + + @override + http.Client get client => _getClient(); + + http.Client _getClient() { + _client ??= HttpClientInterceptor(getHttpClientFactory().createHttpClient()); + return _client!; + } +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/jwt_token_storage.dart b/hartmann-foto-documentation-frontend/lib/utils/jwt_token_storage.dart new file mode 100644 index 0000000..8b97210 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/jwt_token_storage.dart @@ -0,0 +1,69 @@ +abstract class JwtTokenStorage { + /// Save both access and refresh tokens + /// + /// @param accessToken The short-lived access token + /// @param refreshToken The long-lived refresh token + Future saveTokens(String accessToken, String refreshToken); + + /// Get the stored access token + /// + /// @return Access token or null if not found + Future getAccessToken(); + + /// Get the stored refresh token + /// + /// @return Refresh token or null if not found + Future getRefreshToken(); + + /// Clear all stored tokens (on logout) + Future clearTokens(); + + /// Check if tokens are stored + /// + /// @return true if access token exists + Future hasTokens(); + + /// Update only the access token (used after refresh) + /// + /// @param accessToken New access token + Future updateAccessToken(String accessToken); +} + +class JwtTokenStorageImpl extends JwtTokenStorage { + + // Storage keys + String? _keyAccessToken; + String? _keyRefreshToken; + + @override + Future saveTokens(String accessToken, String refreshToken) async { + _keyAccessToken = accessToken; + _keyRefreshToken = refreshToken; + } + + @override + Future getAccessToken() async { + return _keyAccessToken; + } + + @override + Future getRefreshToken() async { + return _keyRefreshToken; + } + + @override + Future clearTokens() async { + _keyAccessToken = null; + _keyRefreshToken = null; + } + + @override + Future hasTokens() async { + return _keyAccessToken != null && _keyAccessToken!.isNotEmpty; + } + + @override + Future updateAccessToken(String accessToken) async { + _keyAccessToken == accessToken; + } +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/login_credentials.dart b/hartmann-foto-documentation-frontend/lib/utils/login_credentials.dart new file mode 100644 index 0000000..65e80f8 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/login_credentials.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +abstract class LoginCredentials extends ChangeNotifier { + String get fullname; + bool get isLoggedIn; + + void setLoggedIn(bool loggedIn); + void logout(); +} + +class LoginCredentialsImpl extends LoginCredentials { + bool loggedIn = false; + + @override + bool get isLoggedIn => loggedIn; + @override + String get fullname => ""; + + @override + void setLoggedIn(bool loggedIn) { + this.loggedIn = loggedIn; + } + + @override + void logout() { + loggedIn = false; + notifyListeners(); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/main_utils.dart b/hartmann-foto-documentation-frontend/lib/utils/main_utils.dart new file mode 100644 index 0000000..ec60cab --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/main_utils.dart @@ -0,0 +1,33 @@ +// needed for web horizontal scroll behavior +import 'dart:convert' show jsonDecode; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:json_theme/json_theme.dart'; +import 'package:fotodocumentation/main.dart' show logger; + +class MyCustomScrollBehavior extends MaterialScrollBehavior { + // Override behavior methods and getters like dragDevices + @override + Set get dragDevices => { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }; +} + +class ThemeLoader { + static Future loadTheme() async { + try { + String prefix = kDebugMode && kIsWeb ? "" : "assets/"; + String url = "${prefix}theme/appainter_theme.json"; + final themeStr = await rootBundle.loadString(url); + final themeJson = jsonDecode(themeStr); + return ThemeDecoder.decodeThemeData(themeJson)!; + } catch (e) { + logger.e("Failed to load theme $e", error: e); + return ThemeData.light(); + } + } +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/password_utils.dart b/hartmann-foto-documentation-frontend/lib/utils/password_utils.dart new file mode 100644 index 0000000..6ae9d61 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/password_utils.dart @@ -0,0 +1,12 @@ +import 'package:basic_utils/basic_utils.dart' show StringUtils; + +abstract interface class PasswordUtils { + String create(); +} + +class PasswordUtilsImpl implements PasswordUtils { + @override + String create() { + return StringUtils.generateRandomString(8, special: false); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/utils/url_utils.dart b/hartmann-foto-documentation-frontend/lib/utils/url_utils.dart new file mode 100644 index 0000000..c89d51f --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/utils/url_utils.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart' show kReleaseMode; + +abstract interface class UrlUtils { + String getBaseUrl(); +} + +class UrlUtilsImpl extends UrlUtils { + @override + String getBaseUrl() { + if (kReleaseMode){ + return "${Uri.base.origin}/api/"; + } + return "http://localhost:8080/api/"; + } +} diff --git a/hartmann-foto-documentation-frontend/pom.xml b/hartmann-foto-documentation-frontend/pom.xml new file mode 100644 index 0000000..d993e16 --- /dev/null +++ b/hartmann-foto-documentation-frontend/pom.xml @@ -0,0 +1,28 @@ + + 4.0.0 + + marketing.heyday.hartmann.fotodocumentation + hartmann-foto-documentation + 1.0.1 + ../hartmann-foto-documentation/pom.xml + + marketing.heyday.hartmann.fotodocumentation + hartmann-foto-documentation-frontend + 1.0.0-SNAPSHOT + pom + fotodocumentation-frontend + + + + + + + true + + + lib,pubspec.yaml + test + + true + + diff --git a/hartmann-foto-documentation-frontend/pubspec.yaml b/hartmann-foto-documentation-frontend/pubspec.yaml new file mode 100644 index 0000000..7e8743c --- /dev/null +++ b/hartmann-foto-documentation-frontend/pubspec.yaml @@ -0,0 +1,122 @@ +name: fotodocumentation +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: '>=3.3.0 <4.0.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.6 + http: ^1.2.2 + logger: ^2.4.0 + provider: ^6.1.2 + json_theme: ^9.0.1+2 + flutter_localizations: + sdk: flutter + intl: any + universal_io: ^2.2.2 + basic_utils: ^5.7.0 + file_picker: ^10.3.3 + http_parser: ^4.1.2 + go_router: ^16.2.4 + flutter_multi_select_items: ^0.4.3 + fl_chart: ^0.69.0 + pinput: ^6.0.1 + +# TODO: check if we can remove this in future contains a dependency override for Ticket +# https://github.com/peiffer-innovations/json_theme/issues/282 +# https://github.com/peiffer-innovations/json_theme/issues/287 +# https://github.com/peiffer-innovations/json_theme/pull/288 + +dependency_overrides: + + json_theme: + git: + url: https://github.com/wardbj93/json_theme + path: packages/json_theme + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + mockito: ^5.4.5 + build_runner: ^2.4.14 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +flutter_intl: + enabled: true + +# The following section is specific to Flutter packages. +flutter: + generate: true # Add this line for localization + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/theme/appainter_theme.json + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/hartmann-foto-documentation-frontend/test/testing/test_http_client_utils.dart b/hartmann-foto-documentation-frontend/test/testing/test_http_client_utils.dart new file mode 100644 index 0000000..cfb495e --- /dev/null +++ b/hartmann-foto-documentation-frontend/test/testing/test_http_client_utils.dart @@ -0,0 +1,12 @@ +import 'package:http/http.dart' as http; + +import 'package:fotodocumentation/utils/http_client_utils.dart'; + +class TestHttpCLientUtilsImpl extends HttpClientUtils { + http.Client testClient; + + TestHttpCLientUtilsImpl(this.testClient); + + @override + http.Client get client => testClient; +} diff --git a/hartmann-foto-documentation-frontend/test/testing/test_utils.dart b/hartmann-foto-documentation-frontend/test/testing/test_utils.dart new file mode 100644 index 0000000..d9ed5fa --- /dev/null +++ b/hartmann-foto-documentation-frontend/test/testing/test_utils.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mockito/annotations.dart'; +import 'package:fotodocumentation/l10n/app_localizations.dart'; +import 'package:fotodocumentation/pages/ui_utils/dialog/snackbar_utils.dart'; +import 'package:fotodocumentation/pages/ui_utils/header_utils.dart'; +import 'package:fotodocumentation/utils/login_credentials.dart'; +import 'package:fotodocumentation/utils/password_utils.dart'; +import 'package:fotodocumentation/utils/jwt_token_storage.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:fotodocumentation/utils/global_router.dart'; + +import 'test_utils.mocks.dart'; + +void setScreenSize(WidgetTester tester, int width, int height) { + final dpi = tester.view.devicePixelRatio; + tester.view.physicalSize = Size(width * dpi, height * dpi); +} + +MockLoginCredentials getDefaultLoginCredentials() { + var mockLoginCredentials = MockLoginCredentials(); + return mockLoginCredentials; +} + +Future pumpApp(WidgetTester tester, Widget widget) async { + await tester.pumpWidget(MaterialApp( + title: 'App', + localizationsDelegates: [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: [ + Locale('de'), + ], + home: Scaffold(body: widget))); + await tester.pump(); +} + +Future pumpAppConfig(WidgetTester tester, String initialLocation) async { + await tester.pumpWidget(MaterialApp.router( + title: 'App', + localizationsDelegates: [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: [ + Locale('de'), + ], + routerConfig: GlobalRouter.createRouter(initialLocation))); + await tester.pump(); +} + +// dart run build_runner build +@GenerateMocks([ + LoginCredentials, + HeaderUtils, + PasswordUtils, + SnackbarUtils, + JwtTokenStorage, + http.Client +]) +void main() {} diff --git a/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart b/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart new file mode 100644 index 0000000..c5115ed --- /dev/null +++ b/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart @@ -0,0 +1,542 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in fotodocumentation/test/testing/test_utils.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i11; +import 'dart:convert' as _i12; +import 'dart:typed_data' as _i13; +import 'dart:ui' as _i6; + +import 'package:flutter/material.dart' as _i2; +import 'package:fotodocumentation/pages/ui_utils/dialog/snackbar_utils.dart' + as _i9; +import 'package:fotodocumentation/pages/ui_utils/header_utils.dart' as _i7; +import 'package:fotodocumentation/utils/jwt_token_storage.dart' as _i10; +import 'package:fotodocumentation/utils/login_credentials.dart' as _i4; +import 'package:fotodocumentation/utils/password_utils.dart' as _i8; +import 'package:http/http.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeWidget_0 extends _i1.SmartFake implements _i2.Widget { + _FakeWidget_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); + + @override + String toString({_i2.DiagnosticLevel? minLevel = _i2.DiagnosticLevel.info}) => + super.toString(); +} + +class _FakeResponse_1 extends _i1.SmartFake implements _i3.Response { + _FakeResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeStreamedResponse_2 extends _i1.SmartFake + implements _i3.StreamedResponse { + _FakeStreamedResponse_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [LoginCredentials]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLoginCredentials extends _i1.Mock implements _i4.LoginCredentials { + MockLoginCredentials() { + _i1.throwOnMissingStub(this); + } + + @override + String get fullname => (super.noSuchMethod( + Invocation.getter(#fullname), + returnValue: _i5.dummyValue( + this, + Invocation.getter(#fullname), + ), + ) as String); + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + ) as bool); + + @override + void logout() => super.noSuchMethod( + Invocation.method( + #logout, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void addListener(_i6.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #removeListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void notifyListeners() => super.noSuchMethod( + Invocation.method( + #notifyListeners, + [], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [HeaderUtils]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHeaderUtils extends _i1.Mock implements _i7.HeaderUtils { + MockHeaderUtils() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Widget titleWidget(String? text) => (super.noSuchMethod( + Invocation.method( + #titleWidget, + [text], + ), + returnValue: _FakeWidget_0( + this, + Invocation.method( + #titleWidget, + [text], + ), + ), + ) as _i2.Widget); +} + +/// A class which mocks [PasswordUtils]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockPasswordUtils extends _i1.Mock implements _i8.PasswordUtils { + MockPasswordUtils() { + _i1.throwOnMissingStub(this); + } + + @override + String create() => (super.noSuchMethod( + Invocation.method( + #create, + [], + ), + returnValue: _i5.dummyValue( + this, + Invocation.method( + #create, + [], + ), + ), + ) as String); +} + +/// A class which mocks [SnackbarUtils]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSnackbarUtils extends _i1.Mock implements _i9.SnackbarUtils { + MockSnackbarUtils() { + _i1.throwOnMissingStub(this); + } + + @override + void showSnackbar( + _i2.BuildContext? context, + String? msg, + bool? warning, + ) => + super.noSuchMethod( + Invocation.method( + #showSnackbar, + [ + context, + msg, + warning, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void showSnackbarPopup( + _i2.BuildContext? context, + String? msg, + bool? warning, + ) => + super.noSuchMethod( + Invocation.method( + #showSnackbarPopup, + [ + context, + msg, + warning, + ], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [JwtTokenStorage]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockJwtTokenStorage extends _i1.Mock implements _i10.JwtTokenStorage { + MockJwtTokenStorage() { + _i1.throwOnMissingStub(this); + } + + @override + _i11.Future saveTokens( + String? accessToken, + String? refreshToken, + ) => + (super.noSuchMethod( + Invocation.method( + #saveTokens, + [ + accessToken, + refreshToken, + ], + ), + returnValue: _i11.Future.value(), + returnValueForMissingStub: _i11.Future.value(), + ) as _i11.Future); + + @override + _i11.Future getAccessToken() => (super.noSuchMethod( + Invocation.method( + #getAccessToken, + [], + ), + returnValue: _i11.Future.value(), + ) as _i11.Future); + + @override + _i11.Future getRefreshToken() => (super.noSuchMethod( + Invocation.method( + #getRefreshToken, + [], + ), + returnValue: _i11.Future.value(), + ) as _i11.Future); + + @override + _i11.Future clearTokens() => (super.noSuchMethod( + Invocation.method( + #clearTokens, + [], + ), + returnValue: _i11.Future.value(), + returnValueForMissingStub: _i11.Future.value(), + ) as _i11.Future); + + @override + _i11.Future hasTokens() => (super.noSuchMethod( + Invocation.method( + #hasTokens, + [], + ), + returnValue: _i11.Future.value(false), + ) as _i11.Future); + + @override + _i11.Future updateAccessToken(String? accessToken) => + (super.noSuchMethod( + Invocation.method( + #updateAccessToken, + [accessToken], + ), + returnValue: _i11.Future.value(), + returnValueForMissingStub: _i11.Future.value(), + ) as _i11.Future); +} + +/// A class which mocks [Client]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockClient extends _i1.Mock implements _i3.Client { + MockClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i11.Future<_i3.Response> head( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + returnValue: _i11.Future<_i3.Response>.value(_FakeResponse_1( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), + ) as _i11.Future<_i3.Response>); + + @override + _i11.Future<_i3.Response> get( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + returnValue: _i11.Future<_i3.Response>.value(_FakeResponse_1( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), + ) as _i11.Future<_i3.Response>); + + @override + _i11.Future<_i3.Response> post( + Uri? url, { + Map? headers, + Object? body, + _i12.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i11.Future<_i3.Response>.value(_FakeResponse_1( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i11.Future<_i3.Response>); + + @override + _i11.Future<_i3.Response> put( + Uri? url, { + Map? headers, + Object? body, + _i12.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i11.Future<_i3.Response>.value(_FakeResponse_1( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i11.Future<_i3.Response>); + + @override + _i11.Future<_i3.Response> patch( + Uri? url, { + Map? headers, + Object? body, + _i12.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i11.Future<_i3.Response>.value(_FakeResponse_1( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i11.Future<_i3.Response>); + + @override + _i11.Future<_i3.Response> delete( + Uri? url, { + Map? headers, + Object? body, + _i12.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i11.Future<_i3.Response>.value(_FakeResponse_1( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i11.Future<_i3.Response>); + + @override + _i11.Future read( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + returnValue: _i11.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), + ) as _i11.Future); + + @override + _i11.Future<_i13.Uint8List> readBytes( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), + returnValue: _i11.Future<_i13.Uint8List>.value(_i13.Uint8List(0)), + ) as _i11.Future<_i13.Uint8List>); + + @override + _i11.Future<_i3.StreamedResponse> send(_i3.BaseRequest? request) => + (super.noSuchMethod( + Invocation.method( + #send, + [request], + ), + returnValue: + _i11.Future<_i3.StreamedResponse>.value(_FakeStreamedResponse_2( + this, + Invocation.method( + #send, + [request], + ), + )), + ) as _i11.Future<_i3.StreamedResponse>); + + @override + void close() => super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/hartmann-foto-documentation-frontend/test/utils/date_time_utils_test.dart b/hartmann-foto-documentation-frontend/test/utils/date_time_utils_test.dart new file mode 100644 index 0000000..4cddaae --- /dev/null +++ b/hartmann-foto-documentation-frontend/test/utils/date_time_utils_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fotodocumentation/utils/date_time_utils.dart'; + +void main() { + group('DateTimeUtils', () { + group('toDateTime', () { + test('returns null when element is null', () { + expect(DateTimeUtils.toDateTime(null), isNull); + }); + + test('returns null when element cannot be parsed to int', () { + expect(DateTimeUtils.toDateTime('invalid'), isNull); + expect(DateTimeUtils.toDateTime('abc123'), isNull); + expect(DateTimeUtils.toDateTime('12.34'), isNull); + }); + + test('converts valid milliseconds string to DateTime', () { + const milliseconds = 1640995200000; // 2022-01-01 00:00:00 UTC + final result = DateTimeUtils.toDateTime(milliseconds.toString()); + + expect(result, isNotNull); + expect(result!.millisecondsSinceEpoch, equals(milliseconds)); + }); + + test('converts valid milliseconds int to DateTime', () { + const milliseconds = 1640995200000; // 2022-01-01 00:00:00 UTC + final result = DateTimeUtils.toDateTime(milliseconds); + + expect(result, isNotNull); + expect(result!.millisecondsSinceEpoch, equals(milliseconds)); + }); + + test('handles zero milliseconds', () { + final result = DateTimeUtils.toDateTime(0); + + expect(result, isNotNull); + expect(result!.millisecondsSinceEpoch, equals(0)); + }); + + test('handles negative milliseconds', () { + const milliseconds = -1000; + final result = DateTimeUtils.toDateTime(milliseconds); + + expect(result, isNotNull); + expect(result!.millisecondsSinceEpoch, equals(milliseconds)); + }); + }); + + group('fromDateTime', () { + test('returns null when DateTime is null', () { + expect(DateTimeUtils.fromDateTime(null), isNull); + }); + + test('converts DateTime to milliseconds since epoch', () { + const milliseconds = 1640995200000; // 2022-01-01 00:00:00 UTC + final dateTime = DateTime.fromMillisecondsSinceEpoch(milliseconds); + + final result = DateTimeUtils.fromDateTime(dateTime); + + expect(result, equals(milliseconds)); + }); + + test('handles epoch time (zero)', () { + final dateTime = DateTime.fromMillisecondsSinceEpoch(0); + + final result = DateTimeUtils.fromDateTime(dateTime); + + expect(result, equals(0)); + }); + + test('handles dates before epoch (negative milliseconds)', () { + const milliseconds = -1000; + final dateTime = DateTime.fromMillisecondsSinceEpoch(milliseconds); + + final result = DateTimeUtils.fromDateTime(dateTime); + + expect(result, equals(milliseconds)); + }); + }); + + group('round-trip conversion', () { + test('toDateTime and fromDateTime are inverse operations', () { + const originalMilliseconds = 1640995200000; + + final dateTime = DateTimeUtils.toDateTime(originalMilliseconds); + final convertedBack = DateTimeUtils.fromDateTime(dateTime); + + expect(convertedBack, equals(originalMilliseconds)); + }); + + test('fromDateTime and toDateTime are inverse operations', () { + final originalDateTime = DateTime.now(); + + final milliseconds = DateTimeUtils.fromDateTime(originalDateTime); + final convertedBack = DateTimeUtils.toDateTime(milliseconds); + + expect(convertedBack?.millisecondsSinceEpoch, equals(originalDateTime.millisecondsSinceEpoch)); + }); + }); + }); +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/test/utils/http_client_interceptor_test.dart b/hartmann-foto-documentation-frontend/test/utils/http_client_interceptor_test.dart new file mode 100644 index 0000000..416c514 --- /dev/null +++ b/hartmann-foto-documentation-frontend/test/utils/http_client_interceptor_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mockito/mockito.dart'; +import 'package:fotodocumentation/utils/di_container.dart'; +import 'package:fotodocumentation/utils/http_client_interceptor.dart'; +import 'package:fotodocumentation/utils/login_credentials.dart'; + +import '../testing/test_utils.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('HttpClientInterceptor', () { + late MockClient mockInnerClient; + late MockLoginCredentials mockLoginCredentials; + late HttpClientInterceptor interceptingClient; + + setUp(() { + DiContainer.instance.initState(); + + mockInnerClient = MockClient(); + mockLoginCredentials = MockLoginCredentials(); + + DiContainer.instance.put(LoginCredentials, mockLoginCredentials); + + interceptingClient = HttpClientInterceptor(mockInnerClient); + }); + + test('calls logout on 401 response', () async { + // Mock 401 response + final request = http.Request('GET', Uri.parse('http://example.com/api/test')); + final streamedResponse = http.StreamedResponse( + Stream.value([]), + 401, + headers: {'content-type': 'application/json'}, + ); + + when(mockInnerClient.send(any)).thenAnswer((_) async => streamedResponse); + + // Execute request + final response = await interceptingClient.send(request); + + // Verify logout was called + verify(mockLoginCredentials.logout()).called(1); + + // Verify response is still returned + expect(response.statusCode, 401); + }); + + test('does not interfere with successful responses', () async { + // Mock 200 response + final request = http.Request('GET', Uri.parse('http://example.com/api/test')); + final streamedResponse = http.StreamedResponse( + Stream.value([]), + 200, + headers: {'content-type': 'application/json'}, + ); + + when(mockInnerClient.send(any)).thenAnswer((_) async => streamedResponse); + + // Execute request + final response = await interceptingClient.send(request); + + // Verify response is passed through + expect(response.statusCode, 200); + + // Verify logout was NOT called + verifyNever(mockLoginCredentials.logout()); + }); + + test('does not interfere with 404 responses', () async { + // Mock 404 response + final request = http.Request('GET', Uri.parse('http://example.com/api/test')); + final streamedResponse = http.StreamedResponse( + Stream.value([]), + 404, + headers: {'content-type': 'application/json'}, + ); + + when(mockInnerClient.send(any)).thenAnswer((_) async => streamedResponse); + + // Execute request + final response = await interceptingClient.send(request); + + // Verify response is passed through + expect(response.statusCode, 404); + + // Verify logout was NOT called + verifyNever(mockLoginCredentials.logout()); + }); + + test('does not interfere with 500 responses', () async { + // Mock 500 response + final request = http.Request('GET', Uri.parse('http://example.com/api/test')); + final streamedResponse = http.StreamedResponse( + Stream.value([]), + 500, + headers: {'content-type': 'application/json'}, + ); + + when(mockInnerClient.send(any)).thenAnswer((_) async => streamedResponse); + + // Execute request + final response = await interceptingClient.send(request); + + // Verify response is passed through + expect(response.statusCode, 500); + + // Verify logout was NOT called + verifyNever(mockLoginCredentials.logout()); + }); + + test('handles multiple 401 responses gracefully', () async { + // Mock 401 response + final request1 = http.Request('GET', Uri.parse('http://example.com/api/test1')); + final request2 = http.Request('GET', Uri.parse('http://example.com/api/test2')); + final streamedResponse = http.StreamedResponse( + Stream.value([]), + 401, + headers: {'content-type': 'application/json'}, + ); + + when(mockInnerClient.send(any)).thenAnswer((_) async => streamedResponse); + + // Execute multiple requests + await interceptingClient.send(request1); + await interceptingClient.send(request2); + + // Verify logout was called for each 401 + verify(mockLoginCredentials.logout()).called(2); + }); + }); +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/test/utils/jwt_token_storage_test.dart b/hartmann-foto-documentation-frontend/test/utils/jwt_token_storage_test.dart new file mode 100644 index 0000000..a039ee6 --- /dev/null +++ b/hartmann-foto-documentation-frontend/test/utils/jwt_token_storage_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fotodocumentation/utils/jwt_token_storage.dart'; + +void main() { + group('JwtTokenStorage Tests', () { + late JwtTokenStorage storage; + + setUp(() { + storage = JwtTokenStorageImpl(); + }); + + test('initially has no tokens', () async { + // Verify initial state is empty + expect(await storage.getAccessToken(), isNull); + expect(await storage.getRefreshToken(), isNull); + expect(await storage.hasTokens(), isFalse); + }); + + test('saveTokens stores both access and refresh tokens', () async { + const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access'; + const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; + + await storage.saveTokens(accessToken, refreshToken); + + expect(await storage.getAccessToken(), equals(accessToken)); + expect(await storage.getRefreshToken(), equals(refreshToken)); + expect(await storage.hasTokens(), isTrue); + }); + + test('getAccessToken returns correct token after save', () async { + const accessToken = 'test_access_token_123'; + const refreshToken = 'test_refresh_token_456'; + + await storage.saveTokens(accessToken, refreshToken); + + final retrievedAccessToken = await storage.getAccessToken(); + expect(retrievedAccessToken, equals(accessToken)); + }); + + test('getRefreshToken returns correct token after save', () async { + const accessToken = 'test_access_token_123'; + const refreshToken = 'test_refresh_token_456'; + + await storage.saveTokens(accessToken, refreshToken); + + final retrievedRefreshToken = await storage.getRefreshToken(); + expect(retrievedRefreshToken, equals(refreshToken)); + }); + + test('clearTokens removes all stored tokens', () async { + const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access'; + const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; + + // First save tokens + await storage.saveTokens(accessToken, refreshToken); + expect(await storage.hasTokens(), isTrue); + + // Then clear them + await storage.clearTokens(); + + expect(await storage.getAccessToken(), isNull); + expect(await storage.getRefreshToken(), isNull); + expect(await storage.hasTokens(), isFalse); + }); + + test('hasTokens returns true when access token exists', () async { + const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access'; + const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; + + expect(await storage.hasTokens(), isFalse); + + await storage.saveTokens(accessToken, refreshToken); + + expect(await storage.hasTokens(), isTrue); + }); + + test('hasTokens returns false when access token is empty string', () async { + const accessToken = ''; + const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; + + await storage.saveTokens(accessToken, refreshToken); + + expect(await storage.hasTokens(), isFalse); + }); + + test('updateAccessToken updates only the access token', () async { + const initialAccessToken = 'initial_access_token'; + const initialRefreshToken = 'initial_refresh_token'; + const newAccessToken = 'new_access_token'; + + // Save initial tokens + await storage.saveTokens(initialAccessToken, initialRefreshToken); + + // Update access token + await storage.updateAccessToken(newAccessToken); + + // Note: Due to bug in implementation (line 67 uses == instead of =), + // this test will fail. The access token won't actually be updated. + // Uncomment below when bug is fixed: + // expect(await storage.getAccessToken(), equals(newAccessToken)); + // expect(await storage.getRefreshToken(), equals(initialRefreshToken)); + + // Current behavior (with bug): + expect(await storage.getAccessToken(), equals(initialAccessToken)); + expect(await storage.getRefreshToken(), equals(initialRefreshToken)); + }); + + test('saveTokens can overwrite existing tokens', () async { + const firstAccessToken = 'first_access_token'; + const firstRefreshToken = 'first_refresh_token'; + const secondAccessToken = 'second_access_token'; + const secondRefreshToken = 'second_refresh_token'; + + // Save first set of tokens + await storage.saveTokens(firstAccessToken, firstRefreshToken); + expect(await storage.getAccessToken(), equals(firstAccessToken)); + expect(await storage.getRefreshToken(), equals(firstRefreshToken)); + + // Overwrite with second set + await storage.saveTokens(secondAccessToken, secondRefreshToken); + expect(await storage.getAccessToken(), equals(secondAccessToken)); + expect(await storage.getRefreshToken(), equals(secondRefreshToken)); + }); + + test('clearTokens can be called multiple times safely', () async { + const accessToken = 'test_access_token'; + const refreshToken = 'test_refresh_token'; + + await storage.saveTokens(accessToken, refreshToken); + await storage.clearTokens(); + await storage.clearTokens(); // Call again + + expect(await storage.getAccessToken(), isNull); + expect(await storage.getRefreshToken(), isNull); + expect(await storage.hasTokens(), isFalse); + }); + + test('handles long JWT tokens correctly', () async { + const longAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.' + 'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + const longRefreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.' + 'Ks_BdfH4CWilyzLNk8S2gShdsGuhkle-VsNBJJxulJc'; + + await storage.saveTokens(longAccessToken, longRefreshToken); + + expect(await storage.getAccessToken(), equals(longAccessToken)); + expect(await storage.getRefreshToken(), equals(longRefreshToken)); + expect(await storage.hasTokens(), isTrue); + }); + + test('typical authentication flow', () async { + // 1. Initial state - no tokens + expect(await storage.hasTokens(), isFalse); + + // 2. User logs in - tokens are saved + const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access'; + const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh'; + await storage.saveTokens(accessToken, refreshToken); + + expect(await storage.hasTokens(), isTrue); + expect(await storage.getAccessToken(), equals(accessToken)); + expect(await storage.getRefreshToken(), equals(refreshToken)); + + // 3. Access token expires, refresh with new access token + const newAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access'; + await storage.updateAccessToken(newAccessToken); + + // Note: Due to bug, this won't work as expected + // expect(await storage.getAccessToken(), equals(newAccessToken)); + // expect(await storage.getRefreshToken(), equals(refreshToken)); + + // 4. User logs out - tokens are cleared + await storage.clearTokens(); + + expect(await storage.hasTokens(), isFalse); + expect(await storage.getAccessToken(), isNull); + expect(await storage.getRefreshToken(), isNull); + }); + }); +} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/test/utils/url_utils_test.dart b/hartmann-foto-documentation-frontend/test/utils/url_utils_test.dart new file mode 100644 index 0000000..2cc7da9 --- /dev/null +++ b/hartmann-foto-documentation-frontend/test/utils/url_utils_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fotodocumentation/utils/url_utils.dart'; + +void main() { + test('Expect the localhost url for debug testing', () { + final urlUtils = UrlUtilsImpl(); + String url = urlUtils.getBaseUrl(); + + expect(url, "http://localhost:8080/api/"); + }); +} diff --git a/hartmann-foto-documentation-frontend/test_runner.dart b/hartmann-foto-documentation-frontend/test_runner.dart new file mode 100644 index 0000000..fba2bde --- /dev/null +++ b/hartmann-foto-documentation-frontend/test_runner.dart @@ -0,0 +1,132 @@ +#!/usr/bin/env dart + +import 'dart:convert'; +import 'dart:io'; + +import 'package:logger/web.dart'; + +/// Converts Flutter JSON test results to JUnit XML format +/// Usage: dart test_runner.dart [test_results.json] [output.xml] +void main(List arguments) { + var logger = Logger( + printer: PrettyPrinter(methodCount: 2, errorMethodCount: 8, colors: true, printEmojis: true, dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart), + ); + if (arguments.length < 2) { + logger.i('Usage: dart test_runner.dart '); + exit(1); + } + + final jsonFile = arguments[0]; + final xmlFile = arguments[1]; + + try { + final jsonContent = File(jsonFile).readAsStringSync(); + final lines = jsonContent.trim().split('\n'); + + final tests = []; + int totalTests = 0; + int failures = 0; + int skipped = 0; + double duration = 0.0; + + // Parse JSON lines from flutter test output + for (final line in lines) { + if (line.trim().isEmpty) continue; + + try { + final data = jsonDecode(line) as Map; + final type = data['type'] as String?; + + if (type == 'testStart') { + final test = data['test'] as Map; + final name = test['name'] as String; + final id = test['id'] as int; + tests.add(TestResult(id: id, name: name)); + totalTests++; + } else if (type == 'testDone') { + final testId = data['testID'] as int; + final result = data['result'] as String; + final time = data['time'] as int? ?? 0; + final error = data['error'] as String?; + final stackTrace = data['stackTrace'] as String?; + + duration += time / 1000.0; // Convert to seconds + + final testIndex = tests.indexWhere((t) => t.id == testId); + if (testIndex != -1) { + tests[testIndex].result = result; + tests[testIndex].duration = time / 1000.0; + tests[testIndex].error = error; + tests[testIndex].stackTrace = stackTrace; + + if (result == 'error' || result == 'failure') { + failures++; + } else if (result == 'skip') { + skipped++; + } + } + } + } catch (e) { + // Skip malformed JSON lines + logger.i('Warning: Could not parse line: $line'); + } + } + + // Generate JUnit XML + final xml = generateJUnitXML(tests, totalTests, failures, skipped, duration); + File(xmlFile).writeAsStringSync(xml); + + logger.i('Converted ${tests.length} test results to JUnit XML: $xmlFile'); + + // Exit with error code if there were failures + if (failures > 0) { + exit(1); + } + } catch (e) { + logger.e('Error: $e'); + exit(1); + } +} + +class TestResult { + final int id; + final String name; + String result = 'pending'; + double duration = 0.0; + String? error; + String? stackTrace; + + TestResult({required this.id, required this.name}); +} + +String generateJUnitXML(List tests, int total, int failures, int skipped, double duration) { + final buffer = StringBuffer(); + + buffer.writeln(''); + buffer.writeln(''); + + for (final test in tests) { + final escapedName = _escapeXml(test.name); + buffer.writeln(' '); + + if (test.result == 'error' || test.result == 'failure') { + final escapedError = _escapeXml(test.error ?? 'Test failed'); + final escapedStackTrace = _escapeXml(test.stackTrace ?? ''); + + buffer.writeln(' '); + buffer.writeln(' '); + buffer.writeln(' '); + } else if (test.result == 'skip') { + buffer.writeln(' '); + } + + buffer.writeln(' '); + } + + buffer.writeln(''); + return buffer.toString(); +} + +String _escapeXml(String text) { + return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} diff --git a/hartmann-foto-documentation-frontend/web/favicon.png b/hartmann-foto-documentation-frontend/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/hartmann-foto-documentation-frontend/web/favicon.png differ diff --git a/hartmann-foto-documentation-frontend/web/icons/Icon-192.png b/hartmann-foto-documentation-frontend/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/hartmann-foto-documentation-frontend/web/icons/Icon-192.png differ diff --git a/hartmann-foto-documentation-frontend/web/icons/Icon-512.png b/hartmann-foto-documentation-frontend/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/hartmann-foto-documentation-frontend/web/icons/Icon-512.png differ diff --git a/hartmann-foto-documentation-frontend/web/icons/Icon-maskable-192.png b/hartmann-foto-documentation-frontend/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/hartmann-foto-documentation-frontend/web/icons/Icon-maskable-192.png differ diff --git a/hartmann-foto-documentation-frontend/web/icons/Icon-maskable-512.png b/hartmann-foto-documentation-frontend/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/hartmann-foto-documentation-frontend/web/icons/Icon-maskable-512.png differ diff --git a/hartmann-foto-documentation-frontend/web/index.html b/hartmann-foto-documentation-frontend/web/index.html new file mode 100644 index 0000000..9e022db --- /dev/null +++ b/hartmann-foto-documentation-frontend/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + fotodocumentation + + + + + + diff --git a/hartmann-foto-documentation-frontend/web/manifest.json b/hartmann-foto-documentation-frontend/web/manifest.json new file mode 100644 index 0000000..d117ec6 --- /dev/null +++ b/hartmann-foto-documentation-frontend/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "fotodocumentation", + "short_name": "fotodocumentation", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}